markdown-it-wikirefs
Version:
markdown-it plugin to add wikirefs (including [[wikilinks]])
1,147 lines (1,064 loc) • 42 kB
JavaScript
// markdown-it-wikirefs v0.0.8 - https://github.com/wikibonsai/markdown-it-wikirefs.git
import { defu } from 'defu';
import * as wikirefs from 'wikirefs';
// perform any file preparations before we do anything --
// (like flush the current file's entry in index if there is one)
const prep_file = (md, opts) => {
// insert the 'prep_file' token after all wikirefs have been tokenized.
// this way, when 'prep_file' calls 'unshift' (see above) it will place the
// render rule in front of all other tokens. otherwise, it might get shuffled
// into an index other than the first one.
md.core.ruler.push('prep_file', prep_file);
function prep_file(state) {
if (opts.prepFile) {
const tok = new state.Token('trigger_prep_file', '', 0);
state.tokens.unshift(tok);
}
}
md.renderer.rules.trigger_prep_file = trigger_prep_file;
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function trigger_prep_file(tokens, index, mdOpts, env) {
if (opts.prepFile) {
opts.prepFile(env);
}
return '';
}
};
const wikiattrs = (md, opts) => {
// rulers
// '.getRules('attrs')' is really testing for markdown-it-'caml'
// 'attrs' is added as an extra dummy 'alt' specifically for this purpose
// (directly accessing the 'caml' rule would be ideal, but markdown-it
// doesn't seem to have a mechanism for that and not sure how to
// attach a 'name' property to sinon fakes)
const attrRules = md.block.ruler.getRules('attrs');
if (opts.attrs.render && attrRules.length === 0) {
// the 'attrbox' rule is the midpoint between the parse and render rules.
md.core.ruler.after('inline', 'wiki_attrbox', wiki_attrbox);
}
// should execute just after 'markdown-it-caml':
// [ ..., 'hr', 'caml', 'wikiattr', 'list', ... ]
md.block.ruler.before('list', 'wikiattr', wikiattr, {
alt: ['paragraph', 'reference', 'blockquote', 'list', 'attrs']
}); // in case bugs show up: [ 'paragraph', 'reference', 'blockquote', 'list' ]
// render
md.renderer.rules.metadata_wikiattr = metadata_wikiattr;
md.renderer.rules.wikiattr_open = wikiattr_open;
md.renderer.rules.wikiattr_key = wikiattr_key;
md.renderer.rules.wikiattr_val = wikiattr_val;
md.renderer.rules.wikiattr_close = wikiattr_close;
// rulers
function wikiattr(state, startLine, endLine, silent) {
// return false cases //
////
// skip indented code blocks
// from: https://github.com/markdown-it/markdown-it/blob/df4607f1d4d4be7fdc32e71c04109aea8cc373fa/lib/rules_block/list.js#L132
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4) {
return false;
}
////
// 'wikiattrs' must be at the top-most-level
// todo: how to interrupt?
// !('list' | 'blockquote' | 'reference' | 'footnote')
if (state.parentType !== 'root' && state.parentType !== 'paragraph') {
return false;
}
////
// check for matches
// 'bMarks' = beginning of line markers
// 'eMarks' = end of line markers
let pos = state.bMarks[startLine];
let max = state.eMarks[startLine];
const thisChunk = state.src.substring(pos, max);
const lineOneMatch = wikirefs.RGX.ATTR_LINE.TYPE.exec(thisChunk);
// no match
if (lineOneMatch === null) {
return false;
}
// is in a list item
// note: this is only necessary for unprefixed wikiattrs
// todo: keep an eye on this...might cause problems...
if (lineOneMatch[0].indexOf('- ') === 0 || lineOneMatch[0].indexOf('* ') === 0 || lineOneMatch[0].indexOf('+ ') === 0) {
return false;
}
// "Don't run any pairs in validation mode":
// 'silent' is used when this rule is being checked against
// in another rule to see whether or not the other rule should
// kick out for this (or some other) one. return 'true' to
// signify that the kick out should happen
if (silent) {
return true;
}
// handle match and return true //
let iterLine = 0;
let m;
const curFilenames = [];
const attrTypeText = lineOneMatch[1].trim();
const filenamesText = lineOneMatch[2];
// links
// - comma-separated list; '2' would be the first wikilink's filename
if (filenamesText !== null && filenamesText !== undefined) {
iterLine += 1;
const gottaCatchEmAll = new RegExp(`${wikirefs.RGX.WIKI.BASE.source}`, 'ig');
// loop through all matches from 'g'lobal regex
// do-while: https://stackoverflow.com/a/6323598
do {
m = gottaCatchEmAll.exec(lineOneMatch[0]);
if (m !== null) {
// m[0]: full match;
// m[1]: filename;
curFilenames.push(m[1]);
}
} while (m);
// - markdown-style list
} else {
// loop through each markdown-style list item
// do-while: https://stackoverflow.com/a/6323598
do {
// increment
iterLine += 1;
pos = state.bMarks[startLine + iterLine];
max = state.eMarks[startLine + iterLine];
const thisSubChunk = state.src.substring(pos, max);
m = wikirefs.RGX.ATTR_LINE.LIST_ITEM.exec(thisSubChunk);
if (m !== null) {
// m[0]: full match;
// m[1]: bullet type;
// m[2]: wikistring;
// m[3]: filename;
curFilenames.push(m[3]);
}
} while (m);
}
// set 'state.env.attrs' to trigger tokens -- if valid.
if (curFilenames.length === 0) {
return false;
} else {
// init
if (!state.env.attrs) {
state.env.attrs = {};
}
if (!state.env.attrs[attrTypeText]) {
state.env.attrs[attrTypeText] = [];
}
// prep renderables
for (const fname of curFilenames) {
state.env.attrs[attrTypeText].push({
type: 'wiki',
filename: fname
});
}
// metadata
if (opts.addAttr) {
const tok = new state.Token('metadata_wikiattr', '', 0);
state.tokens.push(tok);
tok.attrSet('key', attrTypeText);
// note: tokens technically should only accept 'string' or 'null'...but an array of strings works so nicely here...
tok.attrSet('vals', state.env.attrs[attrTypeText].map(item => item.filename));
}
// continue; increment position
state.line += iterLine;
return true;
}
}
function wiki_attrbox(state) {
if (!state.env.attrs || Object.keys(state.env.attrs).length === 0) {
return;
}
const tokens = [];
// open //
const tokOpen = new state.Token('wikiattr_open', '', 0);
// tokOpen.map = [startLine, iterLine];
tokens.push(tokOpen);
// body //
for (const attrtype in state.env.attrs) {
// key / attrtype
const tokType = new state.Token('wikiattr_key', '', 0);
tokType.attrSet('key', attrtype);
tokens.push(tokType);
// values / items
for (const item of state.env.attrs[attrtype]) {
const tokItem = new state.Token('wikiattr_val', '', 1);
if (item.type === 'wiki') {
const filename = item.filename;
if (!filename) {
continue;
}
tokItem.attrSet('key', attrtype);
tokItem.attrSet('val', filename);
}
tokens.push(tokItem);
}
}
// close //
const tokClose = new state.Token('wikiattr_close', '', 0);
tokens.push(tokClose);
// add infobox to **front** of token stream
if (tokens) {
state.tokens = tokens.concat(state.tokens);
}
}
// tokens
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function metadata_wikiattr(tokens, index, mdOpts, env) {
const token = tokens[index];
const attrtype = token.attrGet('key');
// @ts-expect-error: forcing array -- technically not supposed to, but it works so nicely here (see note above)
const filenames = token.attrGet('vals');
if (attrtype && filenames && opts.addAttr) {
for (const filename of filenames) {
opts.addAttr(env, attrtype, filename);
}
}
return '';
}
// render
// example render output:
//
// <aside class="attrbox">
// <dl>
// <dt>attrtype</dt>
// <dd><a class="attr wiki attrtype doctype" href="/tests/fixtures/fname-a" data-href="/tests/fixtures/fname-a">title a</a></dd>
// <dd><a class="attr wiki attrtype doctype" href="/tests/fixtures/fname-b" data-href="/tests/fixtures/fname-b">title b</a></dd>
// <dd><a class="attr wiki attrtype doctype" href="/tests/fixtures/fname-c" data-href="/tests/fixtures/fname-c">title c</a></dd>
// ...
// </dl>
// </aside>
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiattr_open(tokens, index, mdOpts, env) {
return `<aside class="${opts.cssNames.attrbox}">\n<dl>\n`;
}
// attr : key : attrtype
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiattr_key(tokens, index, mdOpts, env) {
const token = tokens[index];
const attrtype = token.attrGet('key');
// Check if there's a previous wikiattr_key (meaning we need to close the previous group div)
let hasPriorKey = false;
for (let i = index - 1; i >= 0; i--) {
if (tokens[i].type === 'wikiattr_key') {
hasPriorKey = true;
break;
}
if (tokens[i].type === 'wikiattr_open') {
break;
}
}
const prefix = hasPriorKey ? `</div>\n<div class="${opts.cssNames.attrItem}">\n` : `<div class="${opts.cssNames.attrItem}">\n`;
return attrtype ? `${prefix}<dt>${attrtype}</dt>\n` : `${prefix}<dt>attrtype error</dt>\n`;
}
// attr : val(s) : item(s)
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiattr_val(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
if (token === null) {
return '<dd>token error</dd>\n';
}
const filename = token.attrGet('val');
if (filename === null) {
return '<dd>filename error</dd>\n';
}
const htmlHref = opts.resolveHtmlHref(env, filename);
const htmlText = opts.resolveHtmlText(env, filename) ? opts.resolveHtmlText(env, filename) : filename;
const doctype = opts.resolveDocType ? opts.resolveDocType(env, filename) : '';
// render
// invalid
if (htmlHref === undefined) {
const wikitext = filename !== null ? filename : 'error';
return `<dd><a class="${opts.cssNames.attr} ${opts.cssNames.wiki} ${opts.cssNames.invalid}">${wikirefs.CONST.MARKER.OPEN}${wikitext}${wikirefs.CONST.MARKER.CLOSE}</a></dd>\n`;
// valid
} else {
const attrtype = token.attrGet('key');
// css
const cssClassArray = [];
// 'attr'
if (attrtype !== null && attrtype !== undefined) {
cssClassArray.push(opts.cssNames.attr);
}
// 'wiki'
cssClassArray.push(opts.cssNames.wiki);
if (attrtype !== null && attrtype !== undefined) {
const attrTypeSlug = wikirefs.slugify(attrtype);
cssClassArray.push(opts.cssNames.reftype + attrTypeSlug);
}
// '<doctype>'
if (doctype !== null && doctype !== undefined && doctype.length !== 0) {
const docTypeSlug = wikirefs.slugify(doctype);
cssClassArray.push(opts.cssNames.doctype + docTypeSlug);
}
const css = cssClassArray.join(' ');
return `<dd><a class="${css}" href="${opts.baseUrl + htmlHref}" data-href="${opts.baseUrl + htmlHref}">${htmlText}</a></dd>\n`;
}
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiattr_close(tokens, index, mdOpts, env) {
delete env.cur_attr_type;
return '</div>\n</dl>\n</aside>\n';
}
};
const wikilinks = (md, opts) => {
// rulers
md.inline.ruler.after('link', 'wikilink', wikilink);
// render
md.renderer.rules.metadata_wikilink = metadata_wikilink;
md.renderer.rules.wikilink_open = wikilink_open;
md.renderer.rules.wikilink_body = wikilink_body;
md.renderer.rules.wikilink_close = wikilink_close;
// rulers
function wikilink(state, silent) {
var _match$;
const srcText = state.src.substring(state.pos);
// process match info
const match = wikirefs.RGX.WIKI.LINK.exec(srcText);
if (match == null || match.length < 1 || match.index !== 0) {
return false;
}
// uncomment in case of skip wikiembeds
// const embedChar: string = state.src.substring(state.pos - 1, state.pos);
// if (embedChar === '!') {
// return false;
// }
const matchText = match[0];
const linkTypeText = match[1] ? match[1].trim() : match[1];
const filenameText = match[2];
const headerText = match[3];
const labelText = (_match$ = match[4]) !== null && _match$ !== void 0 ? _match$ : null;
// handle early kick-out if we've hit a stop-char early //
// untyped
if (!linkTypeText && srcText[0] !== '[') {
return false;
}
// typed
if (linkTypeText && srcText[0] !== ':') {
return false;
}
// "Don't run any pairs in validation mode":
// 'silent' is used when this rule is being checked against
// in another rule to see whether or not the other rule should
// kick out for this (or some other) one. return 'true' to
// signify that the kick out should happen
if (silent) {
return false;
}
let token;
// open //
token = state.push('wikilink_open', 'wikilink', 0);
token.attrSet('filename', filenameText);
if (linkTypeText !== null && linkTypeText !== undefined && linkTypeText.length !== 0) {
token.attrSet('linktype', linkTypeText);
}
if (headerText !== undefined && headerText.length > 0) {
token.attrSet('header', headerText);
}
// body //
token = state.push('wikilink_body', '', 0);
token.attrSet('filename', filenameText);
token.attrSet('matchText', matchText);
if (headerText !== undefined && headerText.length > 0) {
token.attrSet('header', headerText);
}
if (labelText) {
token.attrSet('label', labelText);
}
// close //
token = state.push('wikilink_close', 'wikilink', 0);
// metadata
if (opts.addLink) {
token = state.push('metadata_wikilink', 'wikilink', 0);
token.attrSet('linktype', linkTypeText);
token.attrSet('filename', filenameText);
if (headerText !== undefined && headerText.length > 0) {
token.attrSet('header', headerText);
}
}
// continue; increment position
state.pos += matchText.length;
return true;
}
// render
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function metadata_wikilink(tokens, index, mdOpts, env) {
const token = tokens[index];
if (token === null) {
return 'token error';
}
// don't let 'linktype' be 'null' -- untyped wikilinks have empty strings for 'linktype'
const linktype = token.attrGet('linktype') ? token.attrGet('linktype') : '';
const filename = token.attrGet('filename');
const header = token.attrGet('header');
if (linktype !== null && filename !== null && opts.addLink) {
opts.addLink(env, linktype, filename, header !== null && header !== void 0 ? header : undefined);
}
return '';
}
// render
// typed
//
// <a class="wiki link type linktype doctype" href="/tests/fixtures/fname-a" data-href="/tests/fixtures/fname-a">title a</a>
//
// untyped
//
// <a class="wiki link doctype" href="/tests/fixtures/fname-a" data-href="/tests/fixtures/fname-a">title a</a>
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikilink_open(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
const invalidOpen = `<a class="${opts.cssNames.wiki} ${opts.cssNames.link} ${opts.cssNames.invalid}">`;
if (token === null) {
return invalidOpen;
}
const filename = token.attrGet('filename');
const linktype = token.attrGet('linktype');
const header = token.attrGet('header');
if (filename === null) {
return invalidOpen;
}
let htmlHref = opts.resolveHtmlHref(env, filename);
const doctype = opts.resolveDocType ? opts.resolveDocType(env, filename) : '';
// render
// invalid
if (htmlHref === undefined) {
return invalidOpen;
// valid
} else {
if (header !== null && header.length > 0) {
htmlHref = htmlHref + '#' + wikirefs.slugify(header);
}
// build css string
const cssClassArray = [];
// wikilink
cssClassArray.push(opts.cssNames.wiki);
cssClassArray.push(opts.cssNames.link);
// linktype
if (linktype !== null && linktype !== undefined && linktype.length !== 0) {
cssClassArray.push(opts.cssNames.type);
const linkTypeSlug = wikirefs.slugify(linktype);
cssClassArray.push(opts.cssNames.reftype + linkTypeSlug);
}
// doctype
if (doctype) {
const docTypeSlug = wikirefs.slugify(doctype);
cssClassArray.push(opts.cssNames.doctype + docTypeSlug);
}
const css = cssClassArray.join(' ');
return `<a class="${css}" href="${opts.baseUrl + htmlHref}" data-href="${opts.baseUrl + htmlHref}">`;
}
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikilink_body(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
if (token === null) {
return 'token error';
}
const filename = token.attrGet('filename');
const labelText = token.attrGet('label');
const matchText = token.attrGet('matchText');
if (filename === null) {
return 'filename error';
}
const htmlHref = opts.resolveHtmlHref(env, filename);
const htmlText = opts.resolveHtmlText(env, filename);
// render
// invalid
if (!htmlHref && matchText) {
token.content = matchText;
// valid
} else {
// html text, order of precedence
for (const content of [labelText, htmlText, filename]) {
if (typeof content === 'string' && content.length > 0) {
token.content = content;
break;
}
}
}
return token.content;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikilink_close(tokens, index, mdOpts, env) {
return '</a>';
}
};
function extname(fname) {
const idx = fname.lastIndexOf('.');
return idx >= 0 ? fname.slice(idx) : '';
}
const wikiembeds = (md, opts) => {
// rulers
// the inline rule runs before 'wikilink' so the leading `!` is consumed here.
const wikiLinkRule = md.block.ruler.getRules('wikilink');
if (wikiLinkRule.length === 0) {
md.inline.ruler.before('link', 'wikiembed', wikiembed);
} else {
md.inline.ruler.before('wikilink', 'wikiembed', wikiembed);
}
// core rule: hoist embeds (standalone + mid-text) out of paragraphs to block level
md.core.ruler.push('wikiembed_hoist', wikiembed_hoist_rule);
// render
md.renderer.rules.metadata_wikiembed = metadata_wikiembed;
md.renderer.rules.wikiembed_open = wikiembed_open;
md.renderer.rules.wikiembed_close = wikiembed_close;
// body
md.renderer.rules.wikiembed_content_body_media = wikiembed_content_body_media;
// markdown
// title
md.renderer.rules.wikiembed_title_open = wikiembed_title_open;
md.renderer.rules.wikiembed_title_body = wikiembed_title_body;
md.renderer.rules.wikiembed_title_close = wikiembed_title_close;
// link
md.renderer.rules.wikiembed_link_open = wikiembed_link_open;
md.renderer.rules.wikiembed_link_body = wikiembed_link_body;
md.renderer.rules.wikiembed_link_close = wikiembed_link_close;
// content
md.renderer.rules.wikiembed_content_open = wikiembed_content_open;
md.renderer.rules.wikiembed_content_body_md = wikiembed_content_body_md;
md.renderer.rules.wikiembed_content_close = wikiembed_content_close;
// mid-text embed rendered as inline token (will be hoisted to block by core rule)
md.renderer.rules.wikiembed_link_inline = wikiembed_link_inline;
// rulers
// inline rule: every embed (standalone or mid-text) -> a wikiembed_link_inline token.
// the core hoist rule promotes it out of its paragraph to a block <article>/<figure>.
function wikiembed(state, silent) {
const srcText = state.src.substring(state.pos);
// process match info
const match = wikirefs.RGX.WIKI.EMBED.exec(srcText);
if (match == null || match.length < 1 || match.index !== 0) {
return false;
}
if (silent) {
return false;
}
const matchText = match[0];
const filenameText = match[1];
const headerText = match[2];
const labelText = match[3];
const token = state.push('wikiembed_link_inline', '', 0);
token.attrSet('filename', filenameText);
token.content = matchText;
if (headerText !== undefined && headerText.length > 0) {
token.attrSet('header', headerText);
}
if (labelText !== undefined && labelText.length > 0) {
token.attrSet('label', labelText);
}
// metadata is emitted by the core hoist rule (which owns the block article the embed
// becomes); emitting it here too would double-fire addEmbed AND leave an orphaned
// metadata token that the hoist wraps in a stray <p></p>.
// continue; increment position
state.pos += matchText.length;
return true;
}
// core rule: hoist mid-text embeds out of paragraphs.
//
// Scans the flat token stream for paragraph_open / inline / paragraph_close triples.
// When the inline token's children contain a wikiembed_link_inline, we split the
// children at the embed, emit sibling block tokens:
// [paragraph_open inline(before) paragraph_close]?
// [wikiembed block tokens]
// [paragraph_open inline(after) paragraph_close]?
// Empty / whitespace-only text runs produce no <p>; punctuation-only runs do.
function wikiembed_hoist_rule(state) {
const tokens = state.tokens;
let i = 0;
while (i < tokens.length) {
// look for paragraph triples
if (tokens[i].type !== 'paragraph_open' || !tokens[i + 1] || tokens[i + 1].type !== 'inline' || !tokens[i + 2] || tokens[i + 2].type !== 'paragraph_close') {
i++;
continue;
}
const inlineTok = tokens[i + 1];
const children = inlineTok.children || [];
// check if any child is a wikiembed_link_inline
const embedIdx = children.findIndex(c => c.type === 'wikiembed_link_inline');
if (embedIdx < 0) {
i += 3;
continue;
}
// found one — build replacement tokens
const replacement = [];
// helper: build a paragraph token sequence from a child slice.
// Trims surrounding softbreaks, and trims leading/trailing whitespace from
// the boundary text tokens (the split at an embed boundary leaves trailing/leading
// spaces on text tokens that would render as visible spurious spaces).
const makePara = slice => {
// trim leading/trailing softbreak tokens
let start = 0;
let end = slice.length;
while (start < end && slice[start].type === 'softbreak') {
start++;
}
while (end > start && slice[end - 1].type === 'softbreak') {
end--;
}
// deep-copy the slice so we don't mutate the original children array
const trimmed = slice.slice(start, end).map(t => {
const copy = new state.Token(t.type, t.tag, t.nesting);
copy.content = t.content;
copy.children = t.children;
copy.attrs = t.attrs ? t.attrs.slice() : null;
copy.level = t.level;
copy.map = t.map;
copy.markup = t.markup;
copy.meta = t.meta;
copy.block = t.block;
copy.hidden = t.hidden;
copy.info = t.info;
return copy;
});
if (trimmed.length === 0) {
return [];
}
// trim trailing whitespace from the last text token (split creates a trailing space)
const last = trimmed[trimmed.length - 1];
if (last.type === 'text') {
last.content = last.content.trimEnd();
}
// trim leading whitespace from the first text token (split creates a leading space)
const first = trimmed[0];
if (first.type === 'text') {
first.content = first.content.trimStart();
}
// check if all content is now empty/whitespace
const isWhitespace = trimmed.every(t => (t.type === 'text' || t.type === 'softbreak') && t.content.trim() === '');
if (isWhitespace) {
return [];
}
const pOpen = new state.Token('paragraph_open', 'p', 1);
pOpen.map = tokens[i].map;
pOpen.block = true;
const pInline = new state.Token('inline', '', 0);
pInline.content = '';
pInline.children = trimmed;
pInline.block = true;
const pClose = new state.Token('paragraph_close', 'p', -1);
pClose.block = true;
return [pOpen, pInline, pClose];
};
// helper: build the full block embed token sequence from a wikiembed_link_inline token
const makeEmbedBlock = embedToken => {
const filename = embedToken.attrGet('filename') || '';
const header = embedToken.attrGet('header');
const label = embedToken.attrGet('label');
const out = [];
// open
const openTok = new state.Token('wikiembed_open', 'wikiembed', 0);
openTok.attrSet('filename', filename);
if (header) {
openTok.attrSet('header', header);
}
out.push(openTok);
// body
if (wikirefs.isMedia(filename)) {
const mediaTok = new state.Token('wikiembed_content_body_media', 'wikiembed', 0);
mediaTok.attrSet('filename', filename);
if (header) {
mediaTok.attrSet('header', header);
}
if (label) {
mediaTok.attrSet('label', label);
}
out.push(mediaTok);
} else {
// title
const titleOpen = new state.Token('wikiembed_title_open', 'wikiembed', 0);
out.push(titleOpen);
const titleBody = new state.Token('wikiembed_title_body', 'wikiembed', 0);
titleBody.attrSet('filename', filename);
if (header) {
titleBody.attrSet('header', header);
}
out.push(titleBody);
const titleClose = new state.Token('wikiembed_title_close', 'wikiembed', 0);
out.push(titleClose);
// link
const linkOpen = new state.Token('wikiembed_link_open', 'wikiembed', 0);
linkOpen.attrSet('filename', filename);
if (header) {
linkOpen.attrSet('header', header);
}
out.push(linkOpen);
const linkBody = new state.Token('wikiembed_link_body', 'wikiembed', 0);
linkBody.attrSet('filename', filename);
out.push(linkBody);
const linkClose = new state.Token('wikiembed_link_close', 'wikiembed', 0);
out.push(linkClose);
// content
const contentOpen = new state.Token('wikiembed_content_open', 'wikiembed', 0);
out.push(contentOpen);
const contentBody = new state.Token('wikiembed_content_body_md', 'wikiembed', 0);
contentBody.attrSet('filename', filename);
if (header) {
contentBody.attrSet('header', header);
}
out.push(contentBody);
const contentClose = new state.Token('wikiembed_content_close', 'wikiembed', 0);
out.push(contentClose);
}
// close
const closeTok = new state.Token('wikiembed_close', 'wikiembed', 0);
closeTok.attrSet('filename', filename);
if (header) {
closeTok.attrSet('header', header);
}
out.push(closeTok);
// metadata
if (opts.addEmbed) {
const metaTok = new state.Token('metadata_wikiembed', 'wikiembed', 0);
metaTok.attrSet('filename', filename);
if (header) {
metaTok.attrSet('header', header);
}
out.push(metaTok);
}
return out;
};
// build: text-before para, embed block, text-after para
const beforeChildren = children.slice(0, embedIdx);
const afterChildren = children.slice(embedIdx + 1);
replacement.push(...makePara(beforeChildren));
replacement.push(...makeEmbedBlock(children[embedIdx]));
replacement.push(...makePara(afterChildren));
// splice the 3 original tokens out, insert replacement
tokens.splice(i, 3, ...replacement);
// don't advance i — re-scan from same position (might be another embed in afterChildren)
}
}
// render
// note (markdown) embeds -> a self-contained BLOCK:
//
// <article class="embed-wrapper">
// <div class="embed-title">
// <a class="wiki embed doctype" href="/tests/fixtures/embed-doc" data-href="/tests/fixtures/embed-doc">
// embedded document
// </a>
// </div>
// <div class="embed-link">
// <a class="embed-link-icon" href="/tests/fixtures/embed-doc" data-href="/tests/fixtures/embed-doc">
// <i class="link-icon"></i>
// </a>
// </div>
// <div class="embed-content">
// <p>Here is some content.</p>
// </div>
// </article>
// media embeds (audio, img, video) -> a block <figure>:
//
// <figure class="embed-media" src="audio.mp3" alt="audio.mp3">
// <audio class="embed-audio" controls type="audio/mp3" src="/tests/fixtures/audio.mp3"></audio>
// </figure>
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function metadata_wikiembed(tokens, index, mdOpts, env) {
const token = tokens[index];
if (token === null) {
return 'token error';
}
// don't let 'linktype' be 'null' -- untyped wikiembeds have empty strings for 'linktype'
const filename = token.attrGet('filename');
const header = token.attrGet('header');
if (filename !== null && opts.addEmbed) {
opts.addEmbed(env, filename, header !== null && header !== void 0 ? header : undefined);
}
return '';
}
// embed
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_open(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
const filename = token.attrGet('filename');
if (filename === null) {
return 'filename error';
}
// render
if (wikirefs.isMedia(filename)) {
return '';
} else {
return `<article class="${opts.cssNames.embedWrapper}">\n`;
}
}
// title
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_title_open(tokens, index, mdOpts, env) {
return `<div class="${opts.cssNames.embedTitle}">\n`;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_title_body(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
if (token === null) {
return 'token error';
}
const filename = token.attrGet('filename');
const header = token.attrGet('header');
if (filename === null) {
return 'filename error';
}
let htmlHref = opts.resolveHtmlHref(env, filename);
const htmlText = opts.resolveHtmlText(env, filename) ? opts.resolveHtmlText(env, filename) : filename;
const doctype = opts.resolveDocType ? opts.resolveDocType(env, filename) : '';
// render
if (htmlHref === undefined) {
return `<a class="${opts.cssNames.wiki} ${opts.cssNames.embed} ${opts.cssNames.invalid}">\n${htmlText}\n</a>\n`;
} else {
if (header !== null && header.length > 0) {
htmlHref = htmlHref + '#' + wikirefs.slugify(header);
}
// build css string
const cssClassArray = [];
cssClassArray.push(opts.cssNames.wiki);
cssClassArray.push(opts.cssNames.embed);
// '<doctype>'
if (doctype !== null && doctype !== undefined && doctype.length !== 0) {
const docTypeSlug = wikirefs.slugify(doctype);
cssClassArray.push(opts.cssNames.doctype + docTypeSlug);
}
const css = cssClassArray.join(' ');
return `<a class="${css}" href="${opts.baseUrl + htmlHref}" data-href="${opts.baseUrl + htmlHref}">\n${htmlText}\n</a>\n`;
}
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_title_close(tokens, index, mdOpts, env) {
return '</div>\n';
}
// link
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_link_open(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
const invalidOpen = `<div class="${opts.cssNames.embedLink}">\n<a class="${opts.cssNames.embedLinkIcon} ${opts.cssNames.invalid}">\n`;
if (token === null) {
return invalidOpen;
}
const filename = token.attrGet('filename');
const header = token.attrGet('header');
if (filename === null) {
return invalidOpen;
}
// render
let htmlHref = opts.resolveHtmlHref(env, filename);
// invalid
if (htmlHref === undefined) {
return invalidOpen;
// valid
} else {
if (header !== null && header.length > 0) {
htmlHref = htmlHref + '#' + wikirefs.slugify(header);
}
return `<div class="${opts.cssNames.embedLink}">\n<a class="${opts.cssNames.embedLinkIcon}" href="${opts.baseUrl + htmlHref}" data-href="${opts.baseUrl + htmlHref}">\n`;
}
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_link_body(tokens, index, mdOpts, env) {
const token = tokens[index];
// error
if (token === null) {
return 'token error';
}
return `<i class="${opts.cssNames.linkIcon}"></i>\n`;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_link_close(tokens, index, mdOpts, env) {
return '</a>\n</div>\n';
}
// content
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_content_open(tokens, index, mdOpts, env) {
return `<div class="${opts.cssNames.embedContent}">\n`;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_content_body_md(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
if (token === null) {
return 'token error';
}
const filename = token.attrGet('filename');
if (filename === null) {
return opts.embeds.errorContent + '\'' + filename + '\'';
}
const hText = token.attrGet('header');
const htmlContent = opts.resolveEmbedContent(env, filename, hText !== null && hText !== void 0 ? hText : undefined);
// render
token.content = htmlContent ? htmlContent : opts.embeds.errorContent + '\'' + filename + '\'';
// normalize to exactly one trailing newline (resolved markdown content already ends in
// `\n`; a raw sentinel like 'cycle detected' does not) — no spurious blank before </div>.
return token.content.replace(/\n+$/, '') + '\n';
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_content_body_media(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
if (token === null) {
return 'token error';
}
const filename = token.attrGet('filename');
if (filename === null) {
return 'filename error';
}
const filenameSlug = filename.trim().toLowerCase().replace(/ /g, '-');
const htmlHref = opts.resolveHtmlHref(env, filename);
const mediaExt = extname(filename).toLowerCase();
const mime = extname(filename).replace('.', '').toLowerCase();
const label = token.attrGet('label');
const caption = label ? `<figcaption>${label}</figcaption>\n` : '';
// render: open <figure>
let content = `<figure class="${opts.cssNames.embedMedia}" src="${filenameSlug}" alt="${filenameSlug}">\n`;
// PDF
if (mediaExt === wikirefs.CONST.EXTS.PDF) {
const pdfSrc = htmlHref !== null && htmlHref !== void 0 ? htmlHref : '';
content += `<object class="${opts.cssNames.embedPDF}" data="${pdfSrc}" type="application/pdf">\n`;
content += `<a href="${pdfSrc}">${filenameSlug}</a>\n`;
content += '</object>\n';
// audio
} else if (wikirefs.CONST.EXTS.AUD.has(mediaExt)) {
content += htmlHref ? `<audio class="${opts.cssNames.embedAudio}" controls type="audio/${mime}" src="${htmlHref}"></audio>\n` : `<audio class="${opts.cssNames.embedAudio}" controls type="audio/${mime}"></audio>\n`;
// image — add alt attribute
} else if (wikirefs.CONST.EXTS.IMG.has(mediaExt)) {
content += htmlHref ? `<img class="${opts.cssNames.embedImage}" src="${htmlHref}" alt="${filenameSlug}">\n` : `<img class="${opts.cssNames.embedImage}" alt="${filenameSlug}">\n`;
// video
} else if (wikirefs.CONST.EXTS.VID.has(mediaExt)) {
content += htmlHref ? `<video class="${opts.cssNames.embedVideo}" controls type="video/${mime}" src="${htmlHref}"></video>\n` : `<video class="${opts.cssNames.embedVideo}" controls type="video/${mime}"></video>\n`;
} else {
// note: this is probably not technically possible (due to 'wikirefs.isMedia()' check)
content += 'media error\n';
}
// caption (media only — before closing </figure>)
content += caption;
content += '</figure>\n';
token.content = content;
return token.content;
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_content_close(tokens, index, mdOpts, env) {
return '</div>\n';
}
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_close(tokens, index, mdOpts, env) {
// load
const token = tokens[index];
const filename = token.attrGet('filename');
if (filename === null) {
return 'filename error';
}
// render: the media <figure> is fully emitted (open + media + caption + close) by
// wikiembed_content_body_media, so nothing to close here. note embeds close <article>.
if (wikirefs.isMedia(filename)) {
return '';
} else {
return '</article>\n';
}
}
// mid-text embed -> hoisted to block article by core rule; this renderer is a
// fallback that should not be reached in normal flow.
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
function wikiembed_link_inline(tokens, index, mdOpts, env) {
// This token is consumed by the core hoist rule before rendering; if we end up
// here it means the embed appears outside a paragraph (e.g. already rendered by
// a nested content path). Render it as a raw embed block.
const token = tokens[index];
if (token === null) {
return 'token error';
}
const filename = token.attrGet('filename');
if (filename === null) {
return 'filename error';
}
const header = token.attrGet('header');
token.attrGet('label');
let htmlHref = opts.resolveHtmlHref(env, filename);
const htmlText = opts.resolveHtmlText(env, filename) ? opts.resolveHtmlText(env, filename) : filename;
const doctype = opts.resolveDocType ? opts.resolveDocType(env, filename) : '';
// invalid -> raw display (matches wikilink)
if (htmlHref === undefined) {
return `<a class="${opts.cssNames.wiki} ${opts.cssNames.link} ${opts.cssNames.invalid}">${token.content}</a>`;
}
// valid -> wiki link (fallback; core rule should have hoisted this to block)
if (header !== null && header.length > 0) {
htmlHref = htmlHref + '#' + wikirefs.slugify(header);
}
const cssClassArray = [];
cssClassArray.push(opts.cssNames.wiki);
cssClassArray.push(opts.cssNames.link);
if (doctype !== null && doctype !== undefined && doctype.length !== 0) {
const docTypeSlug = wikirefs.slugify(doctype);
cssClassArray.push(opts.cssNames.doctype + docTypeSlug);
}
const css = cssClassArray.join(' ');
return `<a class="${css}" href="${opts.baseUrl + htmlHref}" data-href="${opts.baseUrl + htmlHref}">${htmlText}</a>`;
}
};
// import
// export
function wikirefs_plugin(md, opts) {
// opts
const defaults = {
resolveHtmlText: (env, fname) => fname.replace(/-/g, ' '),
resolveHtmlHref: (env, fname) => {
const extname = wikirefs.isMedia(fname) ? fname.slice(fname.lastIndexOf('.')) : '';
fname = fname.replace(extname, '');
return '/' + wikirefs.slugify(fname) + extname;
},
resolveEmbedContent: (env, fname, hText) => fname + ' content',
baseUrl: '',
cssNames: {
// wiki
wiki: 'wiki',
invalid: 'invalid',
// kinds
attr: 'attr',
link: 'link',
type: 'type',
embed: 'embed',
// types
reftype: 'reftype__',
doctype: 'doctype__',
// attr
attrbox: 'attrbox',
attrItem: 'attr-item',
// embed
embedWrapper: 'embed-wrapper',
embedTitle: 'embed-title',
embedLink: 'embed-link',
embedContent: 'embed-content',
embedLinkIcon: 'embed-link-icon',
linkIcon: 'link-icon',
embedMedia: 'embed-media',
embedAudio: 'embed-audio',
embedImage: 'embed-image',
embedPDF: 'embed-pdf',
embedVideo: 'embed-video'
},
attrs: {
enable: true,
render: true,
title: 'Attributes'
},
links: {
enable: true
},
embeds: {
enable: true,
title: 'Embed Content',
errorContent: 'Error: Content not found for '
}
};
const fullOpts = defu(opts, defaults);
// by order of execution
if (fullOpts.prepFile) {
prep_file(md, fullOpts);
}
if (fullOpts.attrs && fullOpts.attrs.enable) {
wikiattrs(md, fullOpts);
}
if (fullOpts.links && fullOpts.links.enable) {
wikilinks(md, fullOpts);
}
if (fullOpts.embeds && fullOpts.embeds.enable) {
wikiembeds(md, fullOpts);
}
}
export { wikirefs_plugin as default };
//# sourceMappingURL=index.esm.js.map