UNPKG

marked-caml

Version:

marked extension to add CAML (Colon Attribute Markup Language)

406 lines (386 loc) 16.2 kB
// marked-caml v0.0.5 - https://github.com/wikibonsai/marked-caml.git import { defu } from 'defu'; import * as CAML from 'caml-mkdn'; // exported for test access let attributeCollection = {}; function caml(opts) { // helpers function addToCollection(key, item) { if (!attributeCollection[key]) { attributeCollection[key] = []; } attributeCollection[key].push(item); // metadata callback if (opts.addAttr) { opts.addAttr(key, item.string); } } /** * Get display text for a resolved CAML value. * For multi-line strings (folded/literal), use the resolved value. * For everything else, use the string representation. */ function displayText(item) { let text; if (item.string && item.string.includes('\n')) { text = String(item.value); } else { text = item.string; } // convert newlines to <br> for proper HTML rendering of multi-line values return text.replace(/\n/g, '<br>'); } /** * Render the attrbox HTML from the collected attributes. */ function renderAttributeBox() { if (Object.keys(attributeCollection).length === 0) { return ''; } let html = `<aside class="${opts.cssNames.attrbox || 'attrbox'}">\n`; html += '<dl>\n'; for (const key in attributeCollection) { html += '<div class="' + (opts.cssNames.attrItem || 'attr-item') + '">\n'; html += `<dt>${key}</dt>\n`; for (const item of attributeCollection[key]) { const keySlug = key.trim().toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, ''); // caml does NOT resolve wikirefs. A wiki value renders as a plain string — a // span with the 'string' type class showing the literal [[fname]] — exactly // like any string value. A co-registered marked-wikirefs finds attr spans whose // text is [[...]] and upgrades them to links (the hand-off — see // caml-wikiref-handoff). With no wikirefs, the string span is the output. const typeCls = item.type === 'wiki' ? 'string' : item.type; const display = displayText(item); html += `<dd><span class="${opts.cssNames.attr || 'attr'} ${typeCls} ${keySlug}">${display}</span></dd>\n`; } html += '</div>\n'; } html += '</dl>\n</aside>\n'; return html; } /** * Parse a comma-separated value string into individual value items, * respecting quoted strings. */ function parseCommaValues(valText) { const items = []; let curVal = ''; let inDoubleQuote = false; let inSingleQuote = false; for (const char of valText) { if (!inDoubleQuote && !inSingleQuote && char === ',') { const trimmed = curVal.trim(); if (trimmed.length > 0) { items.push(CAML.resolve(trimmed)); } curVal = ''; continue; } if (/"/.test(char)) { inDoubleQuote = !inDoubleQuote; } if (/'/.test(char)) { inSingleQuote = !inSingleQuote; } curVal += char; } // last value const trimmed = curVal.trim(); if (trimmed.length > 0) { items.push(CAML.resolve(trimmed)); } return items; } /** * Parse mkdn-separated list items. * The listText starts with \n and contains lines like "- value". */ function parseMkdnList(listText) { const items = []; const lines = listText.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const m = CAML.RGX.LINE.LIST_ITEM.exec(line); if (m) { const val = m[2]; // Check if this list item is a multi-line block marker if (new RegExp('^' + CAML.RGX.MARKER.MLINE_STR.source + '$').test(val.trim())) { const marker = val.trim(); const contentLines = []; const pendingEmpty = []; i++; while (i < lines.length) { const nextLine = lines[i]; // continuation line must be indented if (/^\s+\S/.test(nextLine)) { // Flush pending empty lines contentLines.push(...pendingEmpty); pendingEmpty.length = 0; contentLines.push(nextLine); i++; } else if (nextLine.trim().length === 0) { // Could be trailing or in-between empty lines pendingEmpty.push(nextLine); i++; } else { i--; // back up so outer loop processes this line break; } } // Build resolve input // Use full trailing newlines for correct value, fix string to strip one const trailingNewlines = pendingEmpty.length > 0 ? '\n'.repeat(pendingEmpty.length) : ''; const trimmedTrailingNewlines = pendingEmpty.length > 1 ? '\n'.repeat(pendingEmpty.length - 1) : ''; const resolveInput = contentLines.length > 0 ? marker + '\n' + contentLines.join('\n') + trailingNewlines : marker + '\n'; const isKeepMode = marker.endsWith('+'); const mlineItem = CAML.resolve(resolveInput); mlineItem.string = contentLines.length > 0 ? marker + '\n' + contentLines.join('\n') + (isKeepMode ? trailingNewlines : trimmedTrailingNewlines) : marker + '\n'; items.push(mlineItem); } else { items.push(CAML.resolve(val)); } } } return items; } /** * Check if a position in the markdown is at the "top level" * (not inside a code block, blockquote, list item, etc.) */ function isTop(content, position, matchedText) { if (position < 0 || position >= content.length) { return false; } const firstLine = matchedText.split('\n')[0]; // Bullet list item (- * +) if (/^ *[-*+]\s/.test(firstLine)) { return false; } // Numbered list item if (/^ *\d+[.)]\s/.test(firstLine)) { return false; } // Blockquote if (/^ *>\s/.test(firstLine)) { return false; } // Indented code block (4+ spaces or tab) if (/^ {4,}|\t/.test(firstLine)) { return false; } // Fenced code block if (content.includes('```')) { const beforeMatch = content.substring(0, position); const blockMarkers = (beforeMatch.match(/```/g) || []).length; if (blockMarkers % 2 !== 0) { return false; } } // Inline code span if (content.includes('`')) { const beforeMatch = content.substring(0, position); const backticksBefore = (beforeMatch.match(/`/g) || []).length; const afterMatch = content.substring(position); const backticksAfter = (afterMatch.match(/`/g) || []).length; if (backticksBefore % 2 !== 0 && backticksAfter > 0) { return false; } } return true; } return { extensions: [], hooks: { preprocess(markdown) { // Reset attribute collection for each parse attributeCollection = {}; // normalize CRLF -> LF so multi-line block-scalar detection (which keys off // a '\n' right after the >/| indicator + indented continuation lines) works // on files saved with Windows line endings. marked, unlike markdown-it, // doesn't normalize line endings before the preprocess hook. markdown = markdown.replace(/\r\n/g, '\n'); let modified = markdown; const replacements = []; const handledPositions = new Set(); // 1. First pass: handle multi-line single attributes (`:key:: >\n content\n`) // These are standalone CAML attrs whose value is a multi-line block const mlineSingleRgx = new RegExp(CAML.RGX.MLINE.SINGLE.source, 'gim'); let mlineMatch; while ((mlineMatch = mlineSingleRgx.exec(markdown)) !== null) { const fullMatch = mlineMatch[0]; const key = mlineMatch[1].trim(); const marker = mlineMatch[2]; const content = mlineMatch[3]; const start = mlineMatch.index; if (!isTop(markdown, start, fullMatch)) { continue; } // Build the string for resolve // The regex captures one trailing \n as "document end". For blocks with // meaningful trailing newlines (folded > trailing space), we need to preserve // the extra \n for resolve, then fix the string field. const trimmedContent = content.replace(/\n$/, ''); const hasContentLines = /\S/.test(content); const isKeepMode = marker.endsWith('+'); // Empty blocks: use trimmed content. Content blocks: use full content for value const resolveInput = hasContentLines ? ' ' + marker + '\n' + content : ' ' + marker + '\n' + trimmedContent; const item = CAML.resolve(resolveInput); // string field: keep mode preserves all trailing newlines; otherwise strip // the block's trailing newline(s). If another attr/paragraph follows, the // trailing blank line is a separator (strip all); at EOF, keep the single \n. const followedByContent = /\S/.test(markdown.slice(start + fullMatch.length)); const stringContent = isKeepMode ? content : followedByContent ? content.replace(/\n+$/, '') : content.replace(/\n$/, ''); item.string = ' ' + marker + '\n' + stringContent; addToCollection(key, item); handledPositions.add(start); replacements.push({ start: start, end: start + fullMatch.length, replacement: '' }); } // 2. Second pass: handle standard CAML attributes // CAML.RGX.CAML matches both inline values and mkdn-separated lists const camlRgx = new RegExp(CAML.RGX.CAML.source, 'gim'); let camlMatch; while ((camlMatch = camlRgx.exec(markdown)) !== null) { const fullMatch = camlMatch[0]; const key = camlMatch[1].trim(); const valText = camlMatch[2]; const start = camlMatch.index; // Skip if already handled as multi-line if (handledPositions.has(start)) { continue; } if (!isTop(markdown, start, fullMatch)) { continue; } // reject typed wikilinks / trailing text: ']]' followed (after optional // whitespace) by a non-comma, non-']' char — e.g. '[[target]].' or // '[[a]],[[b]] some text'. A comma AFTER the whitespace is a list separator, // so padded lists like '[[a]] , [[b]]' are allowed. if (valText && /\]\][ \t]*[^\s,\]]/.test(valText)) { continue; } // labelled wikilinks are typed wikilinks, not attrs (parity with // markdown-it-caml): a wikiattr value is a bare reference ('[[target]]'), so a // label ('[[target|label]]') means display-text prose — caml stands down and // lets wikirefs render the 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 (valText && /\[\[[^\]]*\|[^\]]*\]\]/.test(valText)) { continue; } // header wikilinks ('[[target#header]]') are section links, not bare wikiattr // refs — like labelled wikilinks, caml stands down and lets wikirefs render the // (typed) wikilink (e.g. 'attrtype::[[fname#h]]' → inline link; ':type::[[fname#h]]' // → typed link). wikirefs-spec marks these 'headers not supported in wikiattrs'. if (valText && /\[\[[^\]]*#[^\]]*\]\]/.test(valText)) { continue; } // strictness: only ONE optional space is allowed after '::' (parity with // wikirefs, which rejects >1 space → not a wikiattr). Newline-led (mkdn-list) // and single-space values are unaffected. if (/::[ \t]{2,}/.test(fullMatch)) { continue; } let items; if (valText.startsWith('\n')) { // mkdn-separated list: value starts with newline followed by list items // Check if any list item ends with a multi-line marker (>, |, >-, >|) const lastListItem = valText.trimEnd().split('\n').pop() || ''; const mkdnMlineCheck = new RegExp('^\\s*[+*-]\\s+' + CAML.RGX.MARKER.MLINE_STR.source + '\\s*$').exec(lastListItem); if (mkdnMlineCheck) { // The CAML regex didn't capture the indented content lines // Look ahead in source for continuation const afterMatch = markdown.substring(start + fullMatch.length); // Collect all the indented/empty lines after the CAML match let extraConsumed = 0; afterMatch.replace(/^((?:\s+\S[^\n]*\n|\s*\n)*)/m, match => { extraConsumed = match.length; return match; }); const mlineExtraContent = afterMatch.substring(0, extraConsumed); // Rebuild valText with the continuation content appended const extendedValText = valText + mlineExtraContent; items = parseMkdnList(extendedValText); // Update replacement to cover extra consumed content replacements.push({ start: start, end: start + fullMatch.length + extraConsumed, replacement: '' }); if (items.length > 0) { for (const item of items) { addToCollection(key, item); } } continue; } else { items = parseMkdnList(valText); } } else { // inline value (single or comma-separated) // multi-line indicators in comma lists are treated as literal strings items = parseCommaValues(valText); } if (items.length === 0) { continue; } for (const item of items) { addToCollection(key, item); } replacements.push({ start: start, end: start + fullMatch.length, replacement: '' }); } // Apply replacements in reverse order replacements.sort((a, b) => b.start - a.start); for (const r of replacements) { modified = modified.substring(0, r.start) + r.replacement + modified.substring(r.end); } return modified; }, postprocess(html) { var _opts$cssNames; // Render attribute box const attrboxHtml = renderAttributeBox(); const doRender = !!(opts.attrs && opts.attrs.render !== false); const hasAttrbox = html.includes(`class="${((_opts$cssNames = opts.cssNames) === null || _opts$cssNames === void 0 ? void 0 : _opts$cssNames.attrbox) || 'attrbox'}"`); const result = doRender && attrboxHtml && !hasAttrbox ? attrboxHtml + html : html; return result; } } }; } function camlExtension() { let opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; // Set default options const defaults = { attrs: { render: true }, cssNames: { attrbox: 'attrbox', attrItem: 'attr-item', attr: 'attr', wiki: 'wiki', invalid: 'invalid', reftype: 'reftype__', doctype: 'doctype__' } // NB: no resolvers here — caml does NOT resolve wikirefs. It owns the attrbox // and emits unresolved wiki markers that a co-registered marked-wikirefs resolves // in a later postprocess (the enrich hand-off — see caml-wikiref-handoff / // ./lib/caml). Resolvers live on wikirefsExtension() ONLY; camlExtension() takes // none. With no wikirefs present, wiki attr values render as literal [[fname]]. }; // defu(opts, defaults): user opts win, defaults fill gaps — parity with wikirefs const fullOpts = defu(opts, defaults); const extension = caml(fullOpts); return extension; } export { camlExtension as default }; //# sourceMappingURL=index.umd.js.map