UNPKG

caml-mkdn

Version:

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

649 lines (617 loc) 20.2 kB
// 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.umd.js.map