caml-mkdn
Version:
Colon Attribute Markup Lanugage -- a YAML-like markup syntax for (semantic) attributes in markdown.
1,173 lines (1,105 loc) • 41 kB
JavaScript
// caml-mkdn v0.0.10 - https://github.com/wikibonsai/caml-mkdn.git
;
Object.defineProperty(exports, '__esModule', { value: true });
var escapeMkdn = require('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.
exports.CONST = void 0;
(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];
})(exports.CONST || (exports.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 */
exports.RGX = void 0;
(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('(?:' + exports.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('(?: *(?<!\\\\)' + exports.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');
})(exports.RGX || (exports.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 && exports.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') && exports.CONST.MLINE_INDICATORS.some(ind => value.trim().startsWith(ind))) {
const indicator = exports.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 ? escapeMkdn.getEscIndices(content) : [];
const lineStarts = [];
{
let off = 0;
for (const ln of lines) {
lineStarts.push(off);
off += ln.length + 1;
}
}
const headerRgx = new RegExp('^' + exports.RGX.MARKER.KEY_PRFX.source + '?' + '(' + exports.RGX.VALID_CHARS.KEY.source + ')' + exports.RGX.MARKER.COL.source + ' *' + exports.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 && escapeMkdn.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 ? escapeMkdn.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('^' + exports.RGX.MARKER.KEY_PRFX.source + '?' + '(' + exports.RGX.VALID_CHARS.KEY.source + ')' + exports.RGX.MARKER.COL.source + '\\n' + '((?:[+*-] [^\\n]*\\n)*?)[+*-] *' + exports.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 && escapeMkdn.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 ? escapeMkdn.getEscIndices(content) : [];
let attrMatch, valMatch;
const attrsGottaCatchEmAll = new RegExp(exports.RGX.CAML, 'gim');
const listItemsGottaCatchEmAll = new RegExp(exports.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 && escapeMkdn.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 (exports.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(exports.RGX.CAML, 'gim');
const multiLineGottaCatchEmAll = new RegExp(exports.RGX.MLINE.SINGLE, 'gim');
const listItemsGottaCatchEmAll = new RegExp(exports.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 = escapeMkdn.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 && escapeMkdn.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 && escapeMkdn.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 (exports.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 + ')?' : '(' + exports.RGX.VALID_CHARS.VAL.source + ')';
const oldRgx = new RegExp('^' + exports.RGX.MARKER_WS.KEY_PRFX.source + key + exports.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 ? escapeMkdn.getEscIndices(content) : [];
let camlAttrMatch = null;
let m;
while ((m = oldRgx.exec(content)) !== null) {
if (!skipEsc || !escapeMkdn.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];
}
exports.TYPE = TYPE;
exports.VAL = VAL;
exports.VAL_HASH = VAL_HASH;
exports.constructYamlTimestamp = constructYamlTimestamp;
exports.dump = dump;
exports.load = load;
exports.parseSexagesimal = parseSexagesimal;
exports.resolve = resolve;
exports.scan = scan;
exports.update = update;
//# sourceMappingURL=index.cjs.js.map