UNPKG

caml-mkdn

Version:

Colon Attribute Markup Lanugage -- a YAML-like markup syntax for (semantic) attributes in markdown.

1,160 lines (1,094 loc) 40.4 kB
// caml-mkdn v0.0.10 - https://github.com/wikibonsai/caml-mkdn.git import { getEscIndices, isStrEscaped } from 'escape-mkdn'; /****************************************************************************** from: https://github.com/eemeli/yaml licensing permission: Copyright Eemeli Aro <eemeli@gmail.com> Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ******************************************************************************/ // 'val' as in, "i want to extract the value" and likely used in conjunction // with other regexes to interact with caml attributes const VAL = { // null NULL: /(?:~|[Nn]ull|NULL)?/, // bool BOOL: /(?:[Tt]rue|TRUE|[Ff]alse|FALSE)/, // int INT: /[-+]?[0-9]+/, INT_HEX: /0x[0-9a-fA-F]+/, INT_OCT: /0o[0-7]+/, // float FLOAT: /[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)/, FLOAT_EXP: /[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+/, FLOAT_NAN: /(?:[-+]?\.(?:inf|Inf|INF|nan|NaN|NAN))/, // time TIME_INT: /[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+/, TIME_FLOAT: /[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*/, TIMESTAMP: RegExp('([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})' + // YYYY-Mm-Dd '(?:' + // (time is optional) '(?:t|T|[ \\t]+)' + // t | T | whitespace '([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}(\\.[0-9]+)?)' + // Hh:Mm:Ss(.ss)? '(?:[ \\t]*(Z|[-+][012]?[0-9](?::[0-9]{2})?))?' + // Z | +5 | -03:30 ')?'), // (explicit) string STRING: /["'].*["']/ }; // 'type' as in, "i want to determine the type of the value" and likely used in isolation // to narrowly determine the type of a given value, perhaps to 'rgx.test(str)' for example. const TYPE = { // null NULL: new RegExp('^' + VAL.NULL.source + '$'), // bool BOOL: new RegExp('^' + VAL.BOOL.source + '$'), // int INT: new RegExp('^' + VAL.INT.source + '$'), INT_HEX: new RegExp('^' + VAL.INT_HEX.source + '$'), INT_OCT: new RegExp('^' + VAL.INT_OCT.source + '$'), // float FLOAT: new RegExp('^' + VAL.FLOAT.source + '$'), FLOAT_EXP: new RegExp('^' + VAL.FLOAT_EXP.source + '$'), FLOAT_NAN: new RegExp('^' + VAL.FLOAT_NAN.source + '$'), // time TIME_INT: new RegExp('^' + VAL.TIME_INT.source + '$'), TIME_FLOAT: new RegExp('^' + VAL.TIME_FLOAT.source + '$'), TIMESTAMP: new RegExp('^' + VAL.TIMESTAMP.source + '$'), // (explicit) string STRING: new RegExp('^' + VAL.STRING.source + '$') }; // from: https://github.com/eemeli/yaml/blob/27bd4faa79c4ff01410c8c6fcf8baa8586769320/src/schema/yaml-1.1/timestamp.ts#L6 function parseSexagesimal(str, asBigInt) { const sign = str[0]; const parts = sign === '-' || sign === '+' ? str.substring(1) : str; const num = n => asBigInt ? BigInt(n) : Number(n); const res = parts.replace(/_/g, '').split(':').reduce((res, p) => res * num(60) + num(p), num(0)); return sign === '-' ? num(-1) * res : res; } //////////////////////////////////////////////////////////////////////////////// // todo: merge time regexes so that there aren't multiple implementations floating around const YAML_DATE_REGEXP = new RegExp('^([0-9][0-9][0-9][0-9])' + // [1] year '-([0-9][0-9])' + // [2] month '-([0-9][0-9])$'); // [3] day const YAML_TIMESTAMP_REGEXP = new RegExp('^([0-9][0-9][0-9][0-9])' + // [1] year '-([0-9][0-9]?)' + // [2] month '-([0-9][0-9]?)' + // [3] day '(?:[Tt]|[ \\t]+)' + // ... '([0-9][0-9]?)' + // [4] hour ':([0-9][0-9])' + // [5] minute ':([0-9][0-9])' + // [6] second '(?:\\.([0-9]*))?' + // [7] fraction '(?:[ \\t]*(Z|([-+])([0-9][0-9]?)' + // [8] tz [9] tz_sign [10] tz_hour '(?::([0-9][0-9]))?))?$'); // [11] tz_minute // from js-yaml: https://github.com/nodeca/js-yaml/blob/master/lib/type/timestamp.js#L29 function constructYamlTimestamp(data) { let year = 0; let month = 0; let day = 0; let hour = 0; let minute = 0; let second = 0; let fraction = 0; let delta = null; let tz_hour; let tz_minute; // note: if this regex causes problems, use js-yaml's: // YAML_DATE_REGEXP : https://github.com/nodeca/js-yaml/blob/master/lib/type/timestamp.js#L5 // YAML_TIMESTAMP_REGEXP: https://github.com/nodeca/js-yaml/blob/master/lib/type/timestamp.js#L10 // const match : RegExpMatchArray | null = VAL.TIMESTAMP.exec(data); // if (match === null) throw new Error('Date resolve error'); // strict 2-digit date form (js-yaml parity): unpadded input like `2025-1-30` matches // neither this nor the timestamp form (which requires a time), so it throws -> the // caller (resolve) falls back to `string`. we do NOT accept unpadded dates. let match = YAML_DATE_REGEXP.exec(data); if (match === null) match = YAML_TIMESTAMP_REGEXP.exec(data); if (match === null) throw new Error('Date resolve error'); // match: [1] year [2] month [3] day year = +match[1]; month = +match[2] - 1; // JS month starts with 0 day = +match[3]; if (!match[4]) { // no hour const dateOnly = new Date(Date.UTC(year, month, day)); // round-trip check: reject out-of-range values (e.g. 2025-13-45, 2025-02-30) instead // of silently rolling over via Date. caller (resolve) catches -> falls back to string. if (dateOnly.getUTCFullYear() !== year || dateOnly.getUTCMonth() !== month || dateOnly.getUTCDate() !== day) { throw new Error('Date resolve error'); } return dateOnly; } // match: [4] hour [5] minute [6] second [7] fraction hour = +match[4]; minute = +match[5]; second = +match[6]; if (match[7]) { fraction = match[7].slice(0, 3); while (fraction.length < 3) { // milli-seconds fraction += '0'; } fraction = +fraction; } // match: [8] tz [9] tz_sign [10] tz_hour [11] tz_minute if (match[9]) { tz_hour = +match[10]; tz_minute = +(match[11] || 0); delta = (tz_hour * 60 + tz_minute) * 60000; // delta in mili-seconds if (match[9] === '-') delta = -delta; } const date = new Date(Date.UTC(year, month, day, hour, minute, second, fraction)); if (delta) date.setTime(date.getTime() - delta); return date; } /* eslint-disable @typescript-eslint/no-namespace */ // plain-string markers — the single source of truth for caml's delimiters. // mirrors wikirefs' `CONST.MARKER` (PREFIX/TYPE): the string-based parsers build // lines from these, the char-by-char parsers (micromark) consume them via // `.charCodeAt()` / `.length`, and `RGX.MARKER` (regex.ts) derives its patterns // from them — so `:` / `::` are declared in exactly one place. let CONST; (function (_CONST) { _CONST.MARKER = { // key prefix, e.g. the leading ':' in ':key::value' KEY_PRFX: ':', // key/value delimiter, e.g. the '::' in 'key::value' COL: '::' }; const MLINE = _CONST.MLINE = { LITERAL: '|', // literal, clip LITERAL_STRIP: '|-', LITERAL_KEEP: '|+', FOLDED: '>', // folded, clip FOLDED_STRIP: '>-', FOLDED_KEEP: '>+' }; _CONST.MLINE_INDICATORS = [MLINE.FOLDED_STRIP, MLINE.FOLDED_KEEP, MLINE.LITERAL_STRIP, MLINE.LITERAL_KEEP, MLINE.FOLDED, MLINE.LITERAL]; })(CONST || (CONST = {})); const VAL_HASH = { 'null': VAL.NULL, // bool 'bool': VAL.BOOL, // int 'int': VAL.INT, 'int_hex': VAL.INT_HEX, 'int_oct': VAL.INT_OCT, // float 'float': VAL.FLOAT, 'float_exp': VAL.FLOAT_EXP, 'float_nan': VAL.FLOAT_NAN, // time 'time_int': VAL.TIME_INT, 'time_float': VAL.TIME_FLOAT, 'timestamp': VAL.TIMESTAMP, // (explicit) string 'string': VAL.STRING }; /* eslint-disable indent */ let RGX; (function (_RGX) { const MARKER = _RGX.MARKER = { // markdown bullet BULLET: /[^\S\r\n]{0,4}([+*-]) /i, // todo: add links // match: wkilink's RGX.SP_CHAR.LINKTYPE_PRFX -- with the exception of the final '?' which is added here // derived from CONST.MARKER.KEY_PRFX (':') so the literal lives in one place KEY_PRFX: new RegExp('(?:' + CONST.MARKER.KEY_PRFX + ' ?)'), // match: wikilink's RGX.SP_CHAR.LINKTYPE // negative lookbehind `(?<!\\)`: a backslash-escaped delimiter (`\::`) is NOT // an attr delimiter — the line renders as plain text (parity with wikirefs, // whose attr value must be an un-escaped `[[wikilink]]`). NOTE: escape-mkdn's // getEscIndices does not flag backslashes, so this escape is handled here in // the delimiter regex (shared by scan/load + all caml parsers) rather than via // isStrEscaped (which still guards code-fence / indent / math escapes). // derived from CONST.MARKER.COL ('::') so the literal lives in one place COL: new RegExp('(?: *(?<!\\\\)' + CONST.MARKER.COL + ' ?)'), // multi-line string indicators (YAML block scalar styles) // ref: https://yaml.org/spec/1.2.2/#81-block-scalar-headers // order matters: longer patterns first to avoid partial matches MLINE_STR: /(>-|>\+|\|-|\|\+|>|\|)/ }; _RGX.MARKER_WS = { KEY_PRFX: /(:? ?)/, COL: /( *)::( ?)/ }; const VALID_CHARS = _RGX.VALID_CHARS = { // todo: add link // match: wikilink's RGX.USABLE_CHAR.LINKTYPE KEY: /[^\n\r!:^|[\]`]+/i, // permissive: used by parsers (micromark) for character-level tokenization VAL: /[^\n]+/, // restrictive: excludes brackets so LINE.KEY doesn't swallow typed wikilinks VAL_LINE: /[^\n[\]]+/ }; _RGX.WIKI = /^\[\[[^\n\r!#:^|[\]]+\]\]$/i; const CAP_GRP = _RGX.CAP_GRP = { KEY: new RegExp('(' + VALID_CHARS.KEY.source + ')'), VAL: new RegExp('(' + VALID_CHARS.VAL.source + ')'), VAL_LINE: new RegExp('(' + VALID_CHARS.VAL_LINE.source + ')'), // multi-line block body: continuation requires >= 2 spaces (or a tab); // blank lines allowed; a line indented < 2 spaces ends the block. VAL_MSTR: /((?:(?:(?:[ ]{2,}|\t).*|[ \t]*)\n)*)/ }; _RGX.LINE = { KEY: new RegExp('^' + MARKER.KEY_PRFX.source + '?' + CAP_GRP.KEY.source + MARKER.COL.source + CAP_GRP.VAL_LINE.source + '?' + '$', 'im'), LIST_ITEM: new RegExp('^' + ' *' + MARKER.BULLET.source + CAP_GRP.VAL.source + '$', 'im') }; _RGX.MLINE = { // standalone multi-line string: ":key:: >\n content" SINGLE: new RegExp('^' + MARKER.KEY_PRFX.source + '?' + CAP_GRP.KEY.source + MARKER.COL.source + ' *' + MARKER.MLINE_STR.source + '\\n' + CAP_GRP.VAL_MSTR.source, 'im'), // multi-line in comma list: "first, >\n content" IN_COMMA: new RegExp(', *' + MARKER.MLINE_STR.source + '\\n' + CAP_GRP.VAL_MSTR.source, 'im'), // multi-line in markdown list: "- >\n content" IN_MKDN_LIST: new RegExp('^' + ' *' + MARKER.BULLET.source + MARKER.MLINE_STR.source + '\\n' + CAP_GRP.VAL_MSTR.source, 'im') }; _RGX.CAML = new RegExp('^' + MARKER.KEY_PRFX.source + '?' + CAP_GRP.KEY.source + MARKER.COL.source + '(' // single + CAP_GRP.VAL.source // list-comma + '(?:, *' + CAP_GRP.VAL.source + ')*' + '|' // list-mkdn + '(?:\n *' + '(?:' + MARKER.BULLET.source + CAP_GRP.VAL.source + ')' + ')+' + '\n)', 'im'); })(RGX || (RGX = {})); function dump(attrs, opts) { var _opts$format, _opts$listFormat, _opts$prefix, _opts$multiLine, _opts$chomp, _opts$indent; if (JSON.stringify(attrs) === '{}') { return ''; } const format = (_opts$format = opts === null || opts === void 0 ? void 0 : opts.format) !== null && _opts$format !== void 0 ? _opts$format : 'pretty'; const listFormat = (_opts$listFormat = opts === null || opts === void 0 ? void 0 : opts.listFormat) !== null && _opts$listFormat !== void 0 ? _opts$listFormat : 'mkdn'; const prefixColon = (_opts$prefix = opts === null || opts === void 0 ? void 0 : opts.prefix) !== null && _opts$prefix !== void 0 ? _opts$prefix : true; const multiLine = (_opts$multiLine = opts === null || opts === void 0 ? void 0 : opts.multiLine) !== null && _opts$multiLine !== void 0 ? _opts$multiLine : 'none'; const chomp = (_opts$chomp = opts === null || opts === void 0 ? void 0 : opts.chomp) !== null && _opts$chomp !== void 0 ? _opts$chomp : 'clip'; const indent = (_opts$indent = opts === null || opts === void 0 ? void 0 : opts.indent) !== null && _opts$indent !== void 0 ? _opts$indent : 2; // validate: multi-line strings are not supported in comma-separated lists if (multiLine !== 'none' && listFormat === 'comma') { const hasMultiLineValues = Object.values(attrs).some(v => Array.isArray(v) && v.some(item => `${item}`.includes('\n'))); if (hasMultiLineValues) { console.warn('dump(): multi-line strings are not supported in comma-separated lists. Use listFormat: \'mkdn\' instead.'); } } let attrString = ''; // find longest key to prettify against let prettyPad = 0; for (const key of Object.keys(attrs)) { prettyPad = prettyPad > key.length ? prettyPad : key.length; } // build dump string for (const [key, value] of Object.entries(attrs)) { // key if (prefixColon) { attrString += ':'; if (format === 'pad' || format === 'pretty') { attrString += ' '; } } attrString += key; switch (format) { case 'pad': { attrString += ' '; break; } case 'pretty': { const pad = prettyPad - key.length + 1; for (let i = 0; i < pad; i++) { attrString += ' '; } break; } } // docol attrString += '::'; // value(s) if (format === 'pad' || format === 'pretty') { attrString += ' '; } // single if (!Array.isArray(value)) { const strVal = `${value}`; // multi-line string serialization if (multiLine !== 'none' && strVal.includes('\n')) { attrString += serializeMultiLine(strVal, multiLine, chomp, indent); } else { attrString += strVal; // multi-line keep mode (|+, >+) strings end with \n\n — don't add another if (strVal.endsWith('\n\n')) ; else { attrString += '\n'; } } // list } else { for (const [i, v] of value.entries()) { switch (listFormat) { case 'comma': if (i === 0) { attrString += v; continue; } else { attrString += ','; if (format === 'pad' || format === 'pretty') { attrString += ' '; } attrString += v; if (i === value.length - 1) { attrString += '\n'; } } break; case 'mkdn': if (i === 0) { attrString += '\n'; } if (format === 'pretty') { for (let i = 0; i < prettyPad + 6; i++) { attrString += ' '; } } const strV = `${v}`; if (multiLine !== 'none' && strV.includes('\n')) { attrString += '- ' + serializeMultiLine(strV, multiLine, chomp, indent); } else { attrString += '- ' + strV + '\n'; } break; default: console.error('not a valid listFormat'); break; } } } } return attrString; } // serialize a value as a YAML block scalar function serializeMultiLine(value, style, chomp, indent) { const indentStr = ' '.repeat(indent); // build indicator const styleChar = style === 'literal' ? '|' : '>'; const chompChar = chomp === 'strip' ? '-' : chomp === 'keep' ? '+' : ''; const indicator = styleChar + chompChar; // strip trailing newlines from value — we'll re-add per chomp mode let content = value.replace(/\n+$/, ''); // for folded style, split long lines at word boundaries let lines; if (style === 'folded') { lines = content.split('\n'); // if a line is long (fully folded), wrap at word boundaries const wrapped = []; for (const line of lines) { if (line.length > 72) { const words = line.split(' '); let curLine = ''; for (const word of words) { if (curLine.length > 0 && curLine.length + word.length + 1 > 72) { wrapped.push(curLine); curLine = word; } else { curLine += (curLine.length > 0 ? ' ' : '') + word; } } if (curLine.length > 0) wrapped.push(curLine); } else { wrapped.push(line); } } lines = wrapped; } else { lines = content.split('\n'); } // build output: indicator + indented lines let result = indicator + '\n'; for (const line of lines) { if (line === '') { result += '\n'; } else { result += indentStr + line + '\n'; } } // add trailing newlines per chomp mode if (chomp === 'keep') { // count how many trailing newlines were in the original value const trailingMatch = value.match(/\n+$/); const trailingCount = trailingMatch ? trailingMatch[0].length : 0; // we already added one \n after the last content line, // so add (trailingCount - 1) more if (trailingCount > 1) { result += '\n'.repeat(trailingCount - 1); } } return result; } // YAML block scalar parser // ref: https://yaml.org/spec/1.2.2/#81-block-scalar-headers // // Style: // literal (|): preserve newlines // folded (>): replace newlines with spaces // // Chomping (controls trailing newlines): // clip (default): single trailing newline // strip (-): no trailing newline // keep (+): preserve all trailing newlines // function parseYamlScalar(indicator, block) { // Parse indicator into style and chomping mode const style = indicator[0]; // > or | const isLiteral = style === '|'; let chompMode; if (indicator.endsWith('-')) { chompMode = 'strip'; } else if (indicator.endsWith('+')) { chompMode = 'keep'; } else { chompMode = 'clip'; } let lines = block.split('\n'); // Count trailing empty lines before stripping let trailingEmptyCount = 0; for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].trim() === '') { trailingEmptyCount++; } else { break; } } // Remove leading empty lines while (lines.length > 0 && lines[0].trim() === '') { lines.shift(); } // Remove trailing empty lines (we'll re-add per chomp mode) while (lines.length > 0 && lines[lines.length - 1].trim() === '') { lines.pop(); } // Find minimum indentation (ignoring empty lines) const nonEmptyLines = lines.filter(line => line.trim() !== ''); let minIndent = Infinity; for (const line of nonEmptyLines) { const indent = line.length - line.trimStart().length; if (indent < minIndent) { minIndent = indent; } } // Remove common indentation if (minIndent !== Infinity && minIndent > 0) { lines = lines.map(line => line.length >= minIndent ? line.slice(minIndent) : line); } // Join lines per style let result; if (isLiteral) { result = lines.join('\n'); } else { result = lines.map(line => line.trim()).join(' '); } // Apply chomping switch (chompMode) { case 'strip': // no trailing newline return result; case 'keep': // preserve all trailing newlines return result + '\n'.repeat(trailingEmptyCount); case 'clip': default: // single trailing newline return result + '\n'; } } // resolve a valid timestamp to a Date; return null for an invalid one (bad format or // out-of-range) so resolution falls back to `string`. constructYamlTimestamp throws on // invalid dates (js-yaml parity) — this contains the throw so resolve() can branch on it // rather than swallow it. type resolution is total: a value that can't be a real date is // a string. function tryTimestamp(value) { try { return constructYamlTimestamp(value); } catch { return null; } } // todo: what if there's leading/trailing whitespace? (trimming beforehand, for now) function resolve(value, opts) { // wikilink — only recognized as a distinct 'wiki' type when the wikirefs plugin is // signalled (opts.wikirefs). caml is wikirefs-agnostic by DEFAULT: `[[x]]` falls // through to a plain string value (brackets kept), leaving link resolution to // wikirefs. if (opts !== null && opts !== void 0 && opts.wikirefs && RGX.WIKI.test(value.trim())) { const trimmed = value.trim(); // strip [[ and ]] to extract filename const filename = trimmed.slice(2, -2); return { type: 'wiki', string: trimmed, value: filename }; } // if the value is a multi-line string, treat it as a string // must contain \n (actual block content) — bare indicators without // content are treated as literal string values // order matters: longer patterns first to avoid partial matches (see CONST.MLINE_INDICATORS) if (value.includes('\n') && CONST.MLINE_INDICATORS.some(ind => value.trim().startsWith(ind))) { const indicator = CONST.MLINE_INDICATORS.find(ind => value.trim().startsWith(ind)); value.trim(); const block = value.slice(value.trim().indexOf(indicator) + indicator.length + 1); const parsed = parseYamlScalar(indicator, block); return { type: 'string', string: value, value: parsed }; } value = value.trim(); // if the value is in single or double quotes, treat it as a string if (value[0] === '\'' && value[value.length] === '\'' || value[0] === '"' && value[value.length] === '"') { return { type: 'string', string: value, value: value }; } // null if (TYPE.NULL.exec(value)) { return { type: 'null', string: 'null', value: null }; } // bool if (TYPE.BOOL.exec(value)) { return { type: 'bool', string: value, value: Boolean(value.toLowerCase() === 'true') }; } // int if (TYPE.INT_HEX.exec(value)) { return { type: 'int', string: value, value: parseInt(value, 16) }; } if (TYPE.INT_OCT.exec(value)) { return { type: 'int', string: value, value: parseInt(value.substring(2, 8), 8) }; } if (TYPE.INT.exec(value)) { return { type: 'int', string: value, value: parseInt(value, 10) }; } // float if (TYPE.FLOAT_EXP.exec(value)) { return { type: 'float', string: value, value: parseFloat(value) }; } if (TYPE.FLOAT_NAN.exec(value)) { return { type: 'float', string: value, value: parseFloat(value) }; } if (TYPE.FLOAT.exec(value)) { return { type: 'float', string: value, value: parseFloat(value) }; } // time if (TYPE.TIMESTAMP.exec(value)) { const time = tryTimestamp(value); if (time !== null) { return { type: 'time', string: value, value: time }; } // else: not a real date -> fall through to `string` } if (TYPE.TIME_INT.exec(value)) { return { type: 'time', string: value, value: parseSexagesimal(value) }; } if (TYPE.TIME_FLOAT.exec(value)) { return { type: 'time', string: value, value: parseSexagesimal(value) }; } // string return { type: 'string', string: value, value: value }; } function preprocessMultiLineStrings(content, res, skipEsc) { let result = content; // Handle standalone multi-line strings first - parse them directly result = handleStandaloneMultiLine(result, res, skipEsc); // Handle multi-line strings in markdown lists // Note: multi-line strings are NOT supported in comma-separated lists. // Indicators (>, |, etc.) in comma lists are treated as literal string values. result = handleMkdnListMultiLine(result, res, skipEsc); return result; } // Shared function for parsing multi-line string content function parseMultiLineString(indicator, blockContent) { const normalizedContent = normalizeMultiLineContent(blockContent); const fullValue = ` ${indicator}\n${normalizedContent}`; const parsed = resolve(fullValue); return parsed.value; } function handleStandaloneMultiLine(content, res, skipEsc) { const lines = content.split('\n'); // escape gate: per-line start offsets into `content` for isStrEscaped const escdIndices = skipEsc ? getEscIndices(content) : []; const lineStarts = []; { let off = 0; for (const ln of lines) { lineStarts.push(off); off += ln.length + 1; } } const headerRgx = new RegExp('^' + RGX.MARKER.KEY_PRFX.source + '?' + '(' + RGX.VALID_CHARS.KEY.source + ')' + RGX.MARKER.COL.source + ' *' + RGX.MARKER.MLINE_STR.source + '$', 'i'); const consumed = new Set(); for (let i = 0; i < lines.length; i++) { const headerMatch = headerRgx.exec(lines[i]); if (!headerMatch) continue; // skip escaped multi-line CAML (e.g. inside a code block) if (skipEsc && isStrEscaped(lines[i], content, lineStarts[i], escdIndices)) { continue; } const trimmedKey = headerMatch[1].trim(); const indicator = headerMatch[2]; consumed.add(i); // collect continuation lines: indented >= 2 spaces (or a tab), or empty // (but stop at a line indented < 2 spaces). matches VAL_MSTR. const blockLines = []; const INDENT_RE = /^(?:[ ]{2,}|\t)/; let j = i + 1; while (j < lines.length) { const line = lines[j]; if (line.trim() === '') { // empty line: include if next line is indented, otherwise end if (j + 1 < lines.length && INDENT_RE.test(lines[j + 1])) { blockLines.push(line); consumed.add(j); j++; } else { // trailing empty line(s) — collect all for keep mode while (j < lines.length && lines[j].trim() === '') { blockLines.push(lines[j]); consumed.add(j); j++; } break; } } else if (INDENT_RE.test(line)) { blockLines.push(line); consumed.add(j); j++; } else { break; } } i = j - 1; const blockContent = blockLines.join('\n'); const parsedValue = parseMultiLineString(indicator, blockContent); res.data[trimmedKey] = parsedValue; } // rebuild content without consumed lines const remaining = lines.filter((_, idx) => !consumed.has(idx)); return remaining.join('\n'); } function handleMkdnListMultiLine(content, res, skipEsc) { const escdIndices = skipEsc ? getEscIndices(content) : []; // markdown allows '-', '+', and '*' as unordered-list bullets; mirror the bullet // char class from the canonical RGX.MARKER.BULLET (= /[^\S\r\n]{0,4}([+*-]) /) so // multi-line list values reach parity with single-line list parsing. (Inlined as a // plain char class rather than reusing BULLET.source to avoid adding a capture group // that would shift the positional args in the .replace() callback below.) // Pattern: ":key::\n- value1\n- >\n multi-line content" const mkdnMultiLinePattern = new RegExp('^' + RGX.MARKER.KEY_PRFX.source + '?' + '(' + RGX.VALID_CHARS.KEY.source + ')' + RGX.MARKER.COL.source + '\\n' + '((?:[+*-] [^\\n]*\\n)*?)[+*-] *' + RGX.MARKER.MLINE_STR.source + '\\s*\\n' + '((?:\\s+.*\\n?)*)', 'gm'); return content.replace(mkdnMultiLinePattern, (match, key, previousItems, indicator, blockContent, offset) => { // skip escaped list multi-line CAML (e.g. inside a code block); leave it in place if (skipEsc && isStrEscaped(match, content, offset, escdIndices)) { return match; } // Parse the multi-line part using shared function const parsedValue = parseMultiLineString(indicator, blockContent); // Parse previous list items const trimmedKey = key.replace(/^: ?/, '').trim(); const values = []; // Extract previous items const itemMatches = previousItems.matchAll(/^[+*-] *([^\n]*)/gm); for (const itemMatch of itemMatches) { const itemValue = itemMatch[1].trim(); if (itemValue) { const itemParsed = resolve(itemValue); values.push(itemParsed.value); } } // Add the multi-line value values.push(parsedValue); // Store the complete array res.data[trimmedKey] = values; // Return empty string to remove this from content return ''; }); } function normalizeMultiLineContent(content) { // Remove common leading whitespace but preserve relative indentation const lines = content.split('\n'); const nonEmptyLines = lines.filter(line => line.trim() !== ''); if (nonEmptyLines.length === 0) return ''; // Find minimum indentation let minIndent = Infinity; for (const line of nonEmptyLines) { const indent = line.length - line.trimStart().length; if (indent < minIndent) { minIndent = indent; } } // Remove common indentation const normalizedLines = lines.map(line => line.length >= minIndent ? line.slice(minIndent) : line); return normalizedLines.join('\n'); } function load(content, opts) { const skipEsc = (opts === null || opts === void 0 ? void 0 : opts.skipEsc) !== undefined ? opts.skipEsc : true; // wikirefs-awareness: recognize `[[x]]` values as 'wiki' type (default false). the // multi-line-block helpers never see a `[[x]]` (block scalars aren't wikilinks), so // only the inline value resolutions below thread it. const wikirefs = (opts === null || opts === void 0 ? void 0 : opts.wikirefs) !== undefined ? opts.wikirefs : false; const res = { data: {}, content: '' }; // Preprocess multi-line strings - they get parsed directly into res.data content = preprocessMultiLineStrings(content, res, skipEsc); const replaceMatches = []; // escape gate: escaped CAML (e.g. inside code blocks) must not be parsed as // attributes; computed on the post-preprocess content this loop parses. const escdIndices = skipEsc ? getEscIndices(content) : []; let attrMatch, valMatch; const attrsGottaCatchEmAll = new RegExp(RGX.CAML, 'gim'); const listItemsGottaCatchEmAll = new RegExp(RGX.LINE.LIST_ITEM, 'gim'); // do-while: https://stackoverflow.com/a/6323598 do { attrMatch = attrsGottaCatchEmAll.exec(content); if (attrMatch) { // extract match text const matchText = attrMatch[0]; // skip escaped CAML (e.g. inside a code block) — leave it in res.content if (skipEsc && isStrEscaped(matchText, content, attrMatch.index, escdIndices)) { continue; } const keyText = attrMatch[1]; const valText = attrMatch[2]; // const keyOffset: number = attrMatch.index + matchText.indexOf(keyText); let itemOffset = 0; // single / list-comma if (valText && !/^\s*$/.exec(valText) && !valText.includes('\n')) { // key const trimmedKey = keyText.trim(); res.data[trimmedKey] = []; // handle quotes and comma-separation (this allows quotes to escape commas) const vals = []; let curVal = ''; let inDoubleQuote = false; let inSingleQuote = false; for (const char of valText) { // comma separation if (!inDoubleQuote && !inSingleQuote && char === ',') { vals.push(curVal); curVal = ''; continue; } // quote if (/"/.test(char)) { inDoubleQuote = !inDoubleQuote; } if (/'/.test(char)) { inSingleQuote = !inSingleQuote; } // char curVal += char; } // single / last value vals.push(curVal); if (vals.length === 1) { const trimmedVal = vals[0].trim(); const valParsed = resolve(trimmedVal, { wikirefs }); itemOffset = matchText.indexOf(trimmedVal, itemOffset); res.data[trimmedKey] = valParsed.value; } else { for (const val of vals) { const trimmedVal = val.trim(); const valParsed = resolve(trimmedVal, { wikirefs }); itemOffset = matchText.indexOf(trimmedVal, itemOffset); res.data[trimmedKey].push(valParsed.value); itemOffset += val.length; } } replaceMatches.push(matchText + '\n'); // newlines not included in match // list-mkdn } else { const trimmedKey = keyText.trim(); if (RGX.LINE.LIST_ITEM.exec(matchText)) { // key res.data[trimmedKey] = []; replaceMatches.push(matchText); // newlines included in match } do { valMatch = listItemsGottaCatchEmAll.exec(matchText); if (valMatch) { const valText = valMatch[2]; const trimmedVal = valText.trim(); const valParsed = resolve(trimmedVal, { wikirefs }); itemOffset = matchText.indexOf(trimmedVal, itemOffset); res.data[trimmedKey].push(valParsed.value); itemOffset += valText.length; } } while (valMatch); } } } while (attrMatch); for (const m of replaceMatches) { content = content.replace(m, ''); } res.content = content; return res; } // scan -- useful for syntax highlights function scan(content, opts) { const res = []; let attrMatch, valMatch; const attrsGottaCatchEmAll = new RegExp(RGX.CAML, 'gim'); const multiLineGottaCatchEmAll = new RegExp(RGX.MLINE.SINGLE, 'gim'); const listItemsGottaCatchEmAll = new RegExp(RGX.LINE.LIST_ITEM, 'gim'); // escape handling const skipEsc = (opts === null || opts === void 0 ? void 0 : opts.skipEsc) !== undefined ? opts.skipEsc : true; // wikirefs-awareness: recognize `[[x]]` values as 'wiki' type (default false) const wikirefs = (opts === null || opts === void 0 ? void 0 : opts.wikirefs) !== undefined ? opts.wikirefs : false; const escdIndices = getEscIndices(content); // Handle multi-line strings first do { attrMatch = multiLineGottaCatchEmAll.exec(content); if (attrMatch) { // extract match text const matchText = attrMatch[0]; const keyText = attrMatch[1]; const indicator = attrMatch[2]; const blockContent = attrMatch[3]; // build results const contentOffset = attrMatch.index; const keyOffset = attrMatch.index + matchText.indexOf(keyText); // skip escaped instances if (skipEsc && isStrEscaped(keyText, content, keyOffset, escdIndices)) { continue; } // Handle multi-line string const fullValue = ` ${indicator}\n${blockContent}`; const valParsed = resolve(fullValue, { wikirefs }); const trimmedKey = keyText.trim(); res.push({ key: { text: trimmedKey, start: keyOffset }, vals: [{ type: valParsed.type, val: { text: valParsed.value, start: contentOffset + matchText.indexOf(indicator) } }] }); } } while (attrMatch); // Handle regular CAML attributes do { attrMatch = attrsGottaCatchEmAll.exec(content); if (attrMatch) { // extract match text const matchText = attrMatch[0]; const keyText = attrMatch[1]; const valText = attrMatch[2]; // build results (handle key alongside values in case keys without values were found) const contentOffset = attrMatch.index; const keyOffset = attrMatch.index + matchText.indexOf(keyText); let itemOffset = 0; // skip escaped instances if (skipEsc && isStrEscaped(keyText, content, keyOffset, escdIndices)) { continue; } if (valText && !/^\s*$/.exec(valText) && !valText.includes('\n') && !/^[>-|]\|?$/.test(valText)) { // key + values const trimmedKey = keyText.trim(); const vals = []; // value(s): // list // single const valParts = valText.includes(',') ? valText.split(',') : [valText]; if (keyText.includes(valParts[0])) { itemOffset += keyOffset + keyText.length; } for (const val of valParts) { const trimmedVal = val.trim(); itemOffset = matchText.indexOf(trimmedVal, itemOffset); const valParsed = resolve(trimmedVal, { wikirefs }); vals.push({ type: valParsed.type, val: { text: trimmedVal, start: contentOffset + itemOffset } }); itemOffset += val.length; } res.push({ key: { text: trimmedKey, start: keyOffset }, vals }); // list-mkdn } else { if (RGX.LINE.LIST_ITEM.exec(matchText)) { const vals = []; do { valMatch = listItemsGottaCatchEmAll.exec(matchText); if (valMatch) { const valText = valMatch[2]; const trimmedVal = valText.trim(); itemOffset = matchText.indexOf(trimmedVal, itemOffset); const valParsed = resolve(trimmedVal, { wikirefs }); vals.push({ type: valParsed.type, val: { text: trimmedVal, start: contentOffset + itemOffset } }); itemOffset += valText.length; } } while (valMatch); res.push({ key: { text: keyText, start: keyOffset }, vals }); } } } } while (attrMatch); // only return the results if both keys and values were found const hasValues = res.some(item => item.vals.length > 0); if (!hasValues) { return []; } else { return res; } } function update(content, key, newVal, opts) { var _opts$format; const type = opts === null || opts === void 0 ? void 0 : opts.type; const format = (_opts$format = opts === null || opts === void 0 ? void 0 : opts.format) !== null && _opts$format !== void 0 ? _opts$format : 'content'; const skipEsc = (opts === null || opts === void 0 ? void 0 : opts.skipEsc) !== undefined ? opts.skipEsc : true; // build regex const typeRgxStr = type && Object.keys(VAL_HASH).includes(type) ? '(' + VAL_HASH[type].source + ')?' : '(' + RGX.VALID_CHARS.VAL.source + ')'; const oldRgx = new RegExp('^' + RGX.MARKER_WS.KEY_PRFX.source + key + RGX.MARKER_WS.COL.source + typeRgxStr, 'mg'); // find first non-escaped match — skipEsc gates escaped CAML (e.g. inside code // blocks) from being rewritten, mirroring wikirefs' string.replace gate. const escdIndices = skipEsc ? getEscIndices(content) : []; let camlAttrMatch = null; let m; while ((m = oldRgx.exec(content)) !== null) { if (!skipEsc || !isStrEscaped(m[0], content, m.index, escdIndices)) { camlAttrMatch = m; break; } } if (camlAttrMatch === null) { return undefined; } // breakdown match const colonPrefixAndPad = camlAttrMatch[1]; const frontPad = camlAttrMatch[2]; // front of '::' const backPad = camlAttrMatch[3]; // back of '::' const oldValue = camlAttrMatch[4]; if (oldValue.includes(',') || oldValue === '\n') { console.error('"update()" does not yet support lists'); return undefined; } // build replacement text const updatedText = colonPrefixAndPad + key + frontPad + '::' + backPad + newVal; const start = camlAttrMatch.index; const end = camlAttrMatch.index + camlAttrMatch[0].length; if (format === 'content') { // splice the single matched attr (consistent with the offsets format; the old // global String.replace rewrote every same-key line with the first's padding). return content.slice(0, start) + updatedText + content.slice(end); } // offsets (default) return [start, end, updatedText]; } export { CONST, RGX, TYPE, VAL, VAL_HASH, constructYamlTimestamp, dump, load, parseSexagesimal, resolve, scan, update }; //# sourceMappingURL=index.esm.js.map