marked-caml
Version:
marked extension to add CAML (Colon Attribute Markup Language)
406 lines (386 loc) • 16.2 kB
JavaScript
// 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