caml-mkdn
Version:
Colon Attribute Markup Lanugage -- a YAML-like markup syntax for (semantic) attributes in markdown.
649 lines (617 loc) • 20.2 kB
JavaScript
// caml-mkdn v0.0.1 - https://github.com/wikibonsai/caml-mkdn.git
/* eslint-disable indent */
/* eslint-disable @typescript-eslint/no-namespace */
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
KEY_PRFX: /(?:: ?)/,
// match: wikilink's RGX.SP_CHAR.LINKTYPE
COL: /(?: *:: ?)/
};
_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,
// todo: now excluding brackets to ignore [[wiki values]]...would be better as a lookahead,
// but not sure how to combine single char excludes with pattern excludes...
VAL: /[^\n[\]]+/
};
const CAP_GRP = _RGX.CAP_GRP = {
KEY: new RegExp('(' + VALID_CHARS.KEY.source + ')'),
VAL: new RegExp('(' + VALID_CHARS.VAL.source + ')')
};
_RGX.LINE = {
KEY: new RegExp('^' + MARKER.KEY_PRFX.source + '?' + CAP_GRP.KEY.source + MARKER.COL.source + CAP_GRP.VAL.source + '?' + '$', 'im'),
LIST_ITEM: new RegExp('^' + ' *' + MARKER.BULLET.source + CAP_GRP.VAL.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 = {}));
/******************************************************************************
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');
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
return new Date(Date.UTC(year, month, day));
}
// 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;
}
// todo:
// - strict strings
// - multiline strings
function dump(attrs, opts) {
if (JSON.stringify(attrs) === '{}') {
return '';
}
const format = opts && Object.keys(opts).includes('format') ? opts.format : 'pretty';
const listFormat = opts && Object.keys(opts).includes('listFormat') ? opts.listFormat : 'mkdn';
const prefixColon = opts && Object.keys(opts).includes('prefix') ? opts.prefix : true;
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)) {
attrString += `${value}\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 += ' ';
}
}
attrString += '- ' + v + '\n';
break;
default:
console.error('not a valid listFormat');
break;
}
}
}
}
return attrString;
}
// todo: what if there's leading/trailing whitespace? (trimming beforehand, for now)
function resolve(value) {
// 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)) {
return {
type: 'time',
string: value,
value: constructYamlTimestamp(value)
};
}
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 load(content) {
const res = {
data: {},
content: ''
};
const replaceMatches = [];
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];
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);
itemOffset = matchText.indexOf(trimmedVal, itemOffset);
res.data[trimmedKey] = valParsed.value;
} else {
for (const val of vals) {
const trimmedVal = val.trim();
const valParsed = resolve(trimmedVal);
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 trimmedVal = valMatch[2].trim();
const valParsed = resolve(trimmedVal);
itemOffset = matchText.indexOf(trimmedVal, itemOffset);
res.data[trimmedKey].push(valParsed.value);
itemOffset += valMatch[2].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) {
const res = [];
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];
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;
if (valText && !/^\s*$/.exec(valText) && !valText.includes('\n')) {
// key
const trimmedKey = keyText.trim();
res.push({
key: [trimmedKey, keyOffset]
});
// value(s): // list // single
const vals = valText.includes(',') ? valText.split(',') : [valText];
if (keyText.includes(vals[0])) {
itemOffset += keyOffset + keyText.length;
}
for (const val of vals) {
const trimmedVal = val.trim();
itemOffset = matchText.indexOf(trimmedVal, itemOffset);
const valParsed = resolve(trimmedVal);
res.push({
type: valParsed.type,
val: [trimmedVal, contentOffset + itemOffset]
});
itemOffset += val.length;
}
// list-mkdn
} else {
if (RGX.LINE.LIST_ITEM.exec(matchText)) {
// key
res.push({
key: [keyText, keyOffset]
});
}
do {
valMatch = listItemsGottaCatchEmAll.exec(matchText);
if (valMatch) {
const trimmedVal = valMatch[2].trim();
itemOffset = matchText.indexOf(trimmedVal, itemOffset);
const valParsed = resolve(trimmedVal);
res.push({
type: valParsed.type,
val: [trimmedVal, contentOffset + itemOffset]
});
itemOffset += valMatch[2].length;
}
} while (valMatch);
}
}
} while (attrMatch);
// only return the results if both keys and values were found
const values = res.filter(item => item.type);
if (values.length === 0) {
return [];
} else {
return res;
}
}
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
};
function scanUpdateAttr(content, key, newVal, type) {
// 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
const camlAttrMatch = oldRgx.exec(content);
if (camlAttrMatch === null) {
return undefined;
}
// breakdown match
// const fullmatch : string = camlAttrMatch[0];
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('"updateAttr()" does not yet support lists');
return undefined;
}
const start = camlAttrMatch.index;
const end = camlAttrMatch.index + camlAttrMatch[0].length;
// build replacement text
const updatedText = colonPrefixAndPad + key + frontPad + '::' + backPad + newVal;
return [start, end, updatedText];
}
function updateAttr(content, key, newVal, type) {
// build regex
const rgxTypedVal = '(' + VAL_HASH[type].source + ')?';
const rgxUntypedVal = RGX.VALID_CHARS.VAL.source;
const rgxVal = type && Object.keys(VAL_HASH).includes(type) ? rgxTypedVal : rgxUntypedVal;
const oldRgx = new RegExp('^' + RGX.MARKER_WS.KEY_PRFX.source + key + RGX.MARKER_WS.COL.source + rgxVal, 'mg');
// find
const camlAttrMatch = oldRgx.exec(content);
if (camlAttrMatch === null) {
return undefined;
}
// breakdown match
// const fullmatch: string = camlAttrMatch[0];
const colonPrefixAndPad = camlAttrMatch[1];
const frontPad = camlAttrMatch[2]; // front of '::'
const backPad = camlAttrMatch[3]; // back of '::'
const oldValue = camlAttrMatch[4];
// todo: disallow updating list attrs?
if (oldValue.includes(',') || oldValue === '\n') {
console.error('"updateAttr()" does not yet support lists');
return undefined;
}
// build replacement text
const updatedText = colonPrefixAndPad + key + frontPad + '::' + backPad + newVal;
return content.replace(oldRgx, updatedText);
}
export { RGX, TYPE, VAL, constructYamlTimestamp, dump, load, parseSexagesimal, resolve, scan, scanUpdateAttr, updateAttr };
//# sourceMappingURL=index.esm.js.map