UNPKG

markdown-it-caml

Version:

Add caml (semantic) attribute syntax for markdown-it.

480 lines (445 loc) 18.4 kB
// markdown-it-caml v0.0.6 - https://github.com/wikibonsai/markdown-it-caml.git 'use strict'; var defu = require('defu'); var CAML = require('caml-mkdn'); function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n["default"] = e; return Object.freeze(n); } var CAML__namespace = /*#__PURE__*/_interopNamespace(CAML); const caml_attrs = (md, opts) => { // caml is wikirefs-agnostic by default: `[[x]]` resolves to a plain string. only when // markdown-it-wikirefs is co-registered (it installs the `wikiattr_val` render rule) do // we ask caml to recognize `[[x]]` as a 'wiki' type, which triggers the hand-off below. // checked at parse time so plugin registration order doesn't matter. const wikirefsPresent = () => !!md.renderer.rules.wikiattr_val; // ruler rules // // the 'caml' block rule is the parse that drives the markdown-it-caml plugin // should execute just before 'markdown-it-wikirefs': // [ ..., 'hr', 'caml', 'wikiattr', 'list', ... ] // // note: 'attrs' is added as an extra dummy 'alt' specifically for markdown-it-wikirefs interop // 'alt' lists the block rules this one may interrupt — parity with markdown-it-wikirefs' // wikiattr rule so a caml attr terminates a preceding paragraph/blockquote/list. md.block.ruler.after('hr', 'caml', caml_rule, { alt: ['paragraph', 'reference', 'blockquote', 'list', 'attrs'] }); // the 'attrbox' rule is the midpoint between the parse and render rules. if (opts.attrs.render) { md.core.ruler.after('inline', 'attrbox', attrbox); } // render rules // md.renderer.rules.metadata_caml = metadata_caml; md.renderer.rules.attr_open = attr_open; md.renderer.rules.attr_key = attr_key; md.renderer.rules.attr_val = attr_val; md.renderer.rules.attr_close = attr_close; // ruler function caml_rule(state, startLine, endLine, silent) { // init if (!state.env.attrs) { state.env.attrs = {}; } // 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; } // 'bMarks' = beginning of line markers // 'eMarks' = end of line markers let pos = state.bMarks[startLine]; let max = state.eMarks[startLine]; // 'attrs' must be at the top-most-level // !('list' | 'blockquote' | 'reference' | 'footnote') if (state.parentType !== 'root' && state.parentType !== 'paragraph') { return false; } const chunk = state.src.substring(pos, max); // LINE.KEY uses restrictive value pattern (excludes brackets) to avoid // swallowing typed wikilinks like ':linktype::[[target]].' // Fallback: if LINE.KEY fails, try permissive match for wiki values, // but reject if there's content after ']]' (typed wikilink indicator) let lineOneMatch = CAML__namespace.RGX.LINE.KEY.exec(chunk); if (lineOneMatch === null) { const permissive = new RegExp('^' + CAML__namespace.RGX.MARKER.KEY_PRFX.source + '?' + '(' + CAML__namespace.RGX.VALID_CHARS.KEY.source + ')' + CAML__namespace.RGX.MARKER.COL.source + '(' + CAML__namespace.RGX.VALID_CHARS.VAL.source + ')?' + '$', 'im'); lineOneMatch = permissive.exec(chunk); if (lineOneMatch === null) { return false; } // reject typed wikilinks / trailing text: ']]' followed (after optional // whitespace) by a non-comma, non-']' char — e.g. '[[target]].' or // '[[target]] text'. A comma AFTER the whitespace is a list separator, so // padded lists like '[[a]] , [[b]]' are allowed (the splitter below trims). // Matches caml-mkdn's load, which accepts the pad but not trailing text. const val = lineOneMatch[2]; if (val && /\]\]\s*[^\s,\]]/.test(val)) { return false; } // labelled wikilinks are typed wikilinks, not attrs: a wikiattr value is a // bare reference ('[[target]]'), so a label ('[[target|label]]') means // display-text prose — caml stands down and lets wikirefs render it as a // typed wikilink (e.g. ':linktype::[[fname|label]]'). (A bare '[[target]]' // stays a wikiattr; a malformed '[fname]' stays a caml string primitive — // only the non-bare wiki forms fall back.) if (val && /\[\[[^\]]*\|[^\]]*\]\]/.test(val)) { return false; } // header wikilinks ('[[target#header]]') are section links, not bare wikiattr // refs — like labelled wikilinks, caml stands down and lets wikirefs render the // (typed) wikilink. wikirefs-spec marks these 'headers not supported in wikiattrs'. if (val && /\[\[[^\]]*#[^\]]*\]\]/.test(val)) { 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; } // strictness: only ONE optional space is allowed after '::' (parity with wikirefs). // COL consumes up to one space, so if the captured value still has LEADING // whitespace, there were >1 spaces after '::' — reject (not a wikiattr). if (lineOneMatch[2] && /^\s/.test(lineOneMatch[2])) { 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 curAttrItems = []; const key = lineOneMatch[1].trim(); const value = lineOneMatch[2]; // helper: collect multi-line block continuation lines and build CamlValData function collectMultiLineBlock(indicator, prefix) { const blockLines = []; // collect continuation lines: indented or empty while (startLine + iterLine < endLine) { const contPos = state.bMarks[startLine + iterLine]; const contMax = state.eMarks[startLine + iterLine]; const contLine = state.src.substring(contPos, contMax); // empty line: part of the block only if more indented content follows. // if the next non-blank line is non-indented (a following attr/paragraph), // this blank is a separator that ends the block (for non-keep modes) — it // must not be swallowed. matches caml-mkdn's stop rule; keep mode (+) // still preserves trailing blanks. if (contLine.trim() === '') { const isKeepMode = indicator.endsWith('+'); let foundNext = false; let nextIndented = false; for (let k = startLine + iterLine + 1; k < endLine; k++) { const lookahead = state.src.substring(state.bMarks[k], state.eMarks[k]); if (lookahead.trim() === '') { continue; } foundNext = true; nextIndented = /^\s/.test(lookahead); break; } // a blank followed by a non-indented line (a following attr/paragraph) // ends the block and is a separator, not block content — so exclude it. // at EOF (no following content) or when more indented content follows, // keep it (chomp handles trailing-newline semantics downstream). if (foundNext && !nextIndented && !isKeepMode) { break; } blockLines.push(contLine); iterLine += 1; continue; } // indented line is part of block if (/^\s/.test(contLine)) { blockLines.push(contLine); iterLine += 1; continue; } // non-empty, non-indented line ends the block break; } const blockContent = blockLines.join('\n'); // for keep mode (+), include trailing blank lines in the raw block const hasTrailingBlank = blockLines.length > 0 && blockLines[blockLines.length - 1].trim() === ''; const isKeepMode = indicator.endsWith('+'); const rawBlock = prefix + indicator + '\n' + blockContent + (isKeepMode && hasTrailingBlank ? '\n' : ''); // use CAML.load on the full attr line to get the correctly processed value // include trailing newline if block ended with an empty line to preserve // trailing newline semantics for folded/literal mode const trailingNewline = !isKeepMode && blockLines.length > 0 && blockLines[blockLines.length - 1] === ''; const fullAttrLine = ':' + key + '::' + rawBlock + (trailingNewline ? '\n' : ''); const loadResult = CAML__namespace.load(fullAttrLine, { wikirefs: wikirefsPresent() }); const processedValue = loadResult && loadResult.data && loadResult.data[key] !== undefined ? String(loadResult.data[key]) : ''; return { type: 'string', string: rawBlock, value: processedValue }; } const MULTILINE_RGX = new RegExp('^' + CAML__namespace.RGX.MARKER.MLINE_STR.source + '$'); // values // - multi-line string (folded >, literal |, chomped >-, >|) if (value !== '' && value !== null && value !== undefined && MULTILINE_RGX.test(value.trim())) { iterLine += 1; const typedItem = collectMultiLineBlock(value.trim(), ' '); curAttrItems.push(typedItem); // - single/comma-separated list } else if (value !== '' && value !== null && value !== undefined) { iterLine += 1; let curVal = ''; let inDoubleQuote = false; let inSingleQuote = false; for (const char of value) { // comma separation if (!inDoubleQuote && !inSingleQuote && char === ',') { curAttrItems.push(curVal.trim()); curVal = ''; continue; } // quote if (/"/.test(char)) { inDoubleQuote = !inDoubleQuote; } if (/'/.test(char)) { inSingleQuote = !inSingleQuote; } // char curVal += char; } // last value: multi-line indicators not supported in comma lists // (treated as literal string values) curAttrItems.push(curVal.trim()); // - mkdn-separated list } else { // loop through each markdown-style list item // do-while: https://stackoverflow.com/a/6323598 do { // increment iterLine += 1; if (startLine + iterLine >= endLine) { m = null; break; } pos = state.bMarks[startLine + iterLine]; max = state.eMarks[startLine + iterLine]; const thisChunk = state.src.substring(pos, max); m = CAML__namespace.RGX.LINE.LIST_ITEM.exec(thisChunk); if (m !== null) { // m[0]: full match; // m[1]: bullet type; // m[2]: filename / value const listItemVal = m[2]; if (MULTILINE_RGX.test(listItemVal.trim())) { // next line starts continuation block iterLine += 1; const typedItem = collectMultiLineBlock(listItemVal.trim(), ''); curAttrItems.push(typedItem); } else { curAttrItems.push(listItemVal); } } } while (m); } // set 'state.env.attrs' to trigger tokens -- if valid. if (curAttrItems.length === 0) { return false; } else { // init if (!state.env.attrs[key]) { state.env.attrs[key] = []; } // prep renderables const resolvedItems = []; for (const attrItem of curAttrItems) { // multi-line items are already resolved if (typeof attrItem === 'object' && attrItem !== null && 'type' in attrItem) { resolvedItems.push(attrItem); } else { const typedItem = CAML__namespace.resolve(attrItem, { wikirefs: wikirefsPresent() }); resolvedItems.push(typedItem); } } for (const item of resolvedItems) { state.env.attrs[key].push(item); } // metadata if (opts.addAttr) { const tok = new state.Token('metadata_caml', '', 0); state.tokens.push(tok); tok.attrSet('key', key); tok.attrSet('vals', state.env.attrs[key].map(item => item.string)); } state.line += iterLine; return true; } } function attrbox(state) { if (!state.env.attrs || JSON.stringify(state.env.attrs) === '{}' || Object.keys(state.env.attrs).length === 0) { return; } const tokens = []; // open // const tokOpen = new state.Token('attr_open', '', 0); // tokOpen.map = [startLine, iterLine]; tokens.push(tokOpen); // body // for (const key in state.env.attrs) { // key / linktype const tokType = new state.Token('attr_key', '', 0); tokType.attrSet('key', key); tokens.push(tokType); // values / items for (const item of state.env.attrs[key]) { let tokItem; // if markdown-it-wikirefs is installed and item is wiki type, // use wikiattr_val token so wikirefs can render it if (item.type === 'wiki' && md.renderer.rules.wikiattr_val) { tokItem = new state.Token('wikiattr_val', '', 1); const filename = item.value; if (!filename) { continue; } tokItem.attrSet('key', key); tokItem.attrSet('val', filename); } else { tokItem = new state.Token('attr_val', '', 1); tokItem.attrSet('key', key); tokItem.attrSet('type', item.type); // for multi-line strings, use 'value' (the processed/joined text) // for all other items, use 'string' (the raw display text) if (item.type === 'string' && item.string && item.string.includes('\n')) { tokItem.attrSet('val', String(item.value)); } else { tokItem.attrSet('val', item.string); } } tokens.push(tokItem); } } // close // const tokClose = new state.Token('attr_close', '', 0); tokens.push(tokClose); // add infobox tokens to **front** of token stream (should occur after flush) if (tokens) { state.tokens = tokens.concat(state.tokens); } } // "render" function metadata_caml(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 /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function attr_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 attr_key(tokens, index, mdOpts, env) { const token = tokens[index]; const key = token.attrGet('key'); // Check if there's a previous attr_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 === 'attr_key') { hasPriorKey = true; break; } if (tokens[i].type === 'attr_open') { break; } } const prefix = hasPriorKey ? `</div>\n<div class="${opts.cssNames.attrItem}">\n` : `<div class="${opts.cssNames.attrItem}">\n`; if (key === null) { return `${prefix}<dt>attr key error</dt>\n`; } else { return `${prefix}<dt>${key}</dt>\n`; } } // attr : val(s) : item(s) /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function attr_val(tokens, index, mdOpts, env) { const token = tokens[index]; // invalid if (token === null) { return '<dd>attr error</dd>\n'; // primitives } else { const key = token.attrGet('key'); const valType = token.attrGet('type'); const strValue = token.attrGet('val'); const slug = s => s.trim().toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, ''); const keySlug = key ? slug(key) : ''; // caml does NOT resolve wikirefs. A wiki value renders as a plain string — a span // with the 'string' type class showing the literal [[fname]] — like any string // value. When markdown-it-wikirefs is co-registered, wiki values are emitted as // 'wikiattr_val' tokens (see attrbox) and resolved by wikirefs, so this renderer // only ever sees the standalone case. See caml-wikiref-handoff. // convert newlines to <br> for proper HTML rendering of multi-line values const typeCls = valType === 'wiki' ? 'string' : valType; const displayValue = strValue ? strValue.replace(/\n/g, '<br>') : ''; const rendered = `<span class="${opts.cssNames.attr} ${typeCls} ${keySlug}">${displayValue}</span>`; return `<dd>${rendered}</dd>\n`; } } /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function attr_close(tokens, index, mdOpts, env) { delete env.cur_attr_key; return '</div>\n</dl>\n</aside>\n'; } }; function caml_plugin(md, opts) { // opts const defaults = { attrs: { enable: true, render: true }, cssNames: { attr: 'attr', wiki: 'wiki', invalid: 'invalid', reftype: 'reftype__', doctype: 'doctype__', attrbox: 'attrbox', attrItem: 'attr-item' } }; // defu(opts, defaults): user opts win, defaults fill gaps — parity with wikirefs const fullOpts = defu.defu(opts, defaults); caml_attrs(md, fullOpts); } module.exports = caml_plugin; //# sourceMappingURL=index.cjs.js.map