@intlayer/core
Version:
Includes core Intlayer functions like translation, dictionary, and utility functions shared across multiple packages.
339 lines (337 loc) • 12.1 kB
JavaScript
import { ATTRIBUTES_TO_SANITIZE, CAPTURE_LETTER_AFTER_HYPHEN, CR_NEWLINE_R, DURATION_DELAY_TRIGGER, FORMFEED_R, HTML_CUSTOM_ATTR_R, INTERPOLATION_R, TABLE_CENTER_ALIGN, TABLE_LEFT_ALIGN, TABLE_RIGHT_ALIGN, TABLE_TRIM_PIPES, TAB_R, UNESCAPE_R } from "./constants.mjs";
//#region src/markdown/utils.ts
/**
* Trim trailing whitespace from a string.
*/
const trimEnd = (str) => {
let end = str.length;
while (end > 0 && str[end - 1] <= " ") end--;
return str.slice(0, end);
};
/**
* Check if string starts with prefix.
*/
const startsWith = (str, prefix) => {
return str.startsWith(prefix);
};
/**
* Remove symmetrical leading and trailing quotes.
*/
const unquote = (str) => {
const first = str[0];
if ((first === "\"" || first === "'") && str.length >= 2 && str[str.length - 1] === first) return str.slice(1, -1);
return str;
};
/**
* Unescape backslash-escaped characters.
*/
const unescapeString = (rawString) => rawString ? rawString.replace(UNESCAPE_R, "$1") : rawString;
/**
* Join class names, filtering out falsy values.
*/
const cx = (...args) => args.filter(Boolean).join(" ");
/**
* Get a nested property from an object using dot notation.
*/
const get = (src, path, fb) => {
let ptr = src;
const frags = path.split(".");
while (frags.length) {
ptr = ptr[frags[0]];
if (ptr === void 0) break;
else frags.shift();
}
return ptr ?? fb;
};
/**
* Convert a string to a URL-safe slug.
* Based on https://stackoverflow.com/a/18123682/1141611
*/
const slugify = (str) => str.replace(/[ÀÁÂÃÄÅàáâãä忯]/g, "a").replace(/[çÇ]/g, "c").replace(/[ðÐ]/g, "d").replace(/[ÈÉÊËéèêë]/g, "e").replace(/[ÏïÎîÍíÌì]/g, "i").replace(/[Ññ]/g, "n").replace(/[øØœŒÕõÔôÓóÒò]/g, "o").replace(/[ÜüÛûÚúÙù]/g, "u").replace(/[ŸÿÝý]/g, "y").replace(/[^a-z0-9- ]/gi, "").replace(/ /gi, "-").toLowerCase();
const SANITIZE_R = /(javascript|vbscript|data(?!:image)):/i;
/**
* Sanitize URLs to prevent XSS attacks.
* Returns null if the URL is unsafe.
*/
const sanitizer = (input) => {
try {
const decoded = decodeURIComponent(input).replace(/[^A-Za-z0-9/:]/g, "");
if (SANITIZE_R.test(decoded)) {
console.warn("Input contains an unsafe JavaScript/VBScript/data expression, it will not be rendered.", decoded);
return null;
}
} catch (_e) {
console.warn("Input could not be decoded due to malformed syntax or characters, it will not be rendered.", input);
return null;
}
return input;
};
/**
* Normalize whitespace in source string.
*/
const normalizeWhitespace = (source) => {
const start = performance.now();
const result = source.replace(CR_NEWLINE_R, "\n").replace(FORMFEED_R, "").replace(TAB_R, " ");
const duration = performance.now() - start;
if (duration > 20) console.log(`normalizeWhitespace: ${duration.toFixed(3)}ms, source length: ${source.length}`);
return result;
};
/**
* Safely remove a uniform leading indentation from lines, but do NOT touch
* the content inside fenced code blocks (``` or ~~~).
*/
const trimLeadingWhitespaceOutsideFences = (text, whitespace) => {
const start = performance.now();
if (!whitespace) return text;
const lines = text.split("\n");
const result = lines.map((line) => line.startsWith(whitespace) ? line.slice(whitespace.length) : line).join("\n");
const duration = performance.now() - start;
if (duration > 20) console.log(`trimLeadingWhitespaceOutsideFences: ${duration.toFixed(3)}ms, text length: ${text.length}, lines count: ${lines.length}`);
return result;
};
/**
* Normalize HTML attribute key to JSX prop name.
*/
const normalizeAttributeKey = (key) => {
if (key.indexOf("-") !== -1 && key.match(HTML_CUSTOM_ATTR_R) === null) key = key.replace(CAPTURE_LETTER_AFTER_HYPHEN, (_, letter) => {
return letter.toUpperCase();
});
return key;
};
/**
* Parse a CSS style string into an array of [key, value] tuples.
*/
const parseStyleAttribute = (styleString) => {
const start = performance.now();
const styles = [];
let buffer = "";
let inUrl = false;
let inQuotes = false;
let quoteChar = "";
if (!styleString) return styles;
for (let i = 0; i < styleString.length; i++) {
const char = styleString[i];
if ((char === "\"" || char === "'") && !inUrl) {
if (!inQuotes) {
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar) {
inQuotes = false;
quoteChar = "";
}
}
if (char === "(" && buffer.endsWith("url")) inUrl = true;
else if (char === ")" && inUrl) inUrl = false;
if (char === ";" && !inQuotes && !inUrl) {
const declaration = buffer.trim();
if (declaration) {
const colonIndex = declaration.indexOf(":");
if (colonIndex > 0) {
const key = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
styles.push([key, value]);
}
}
buffer = "";
} else buffer += char;
}
const declaration = buffer.trim();
if (declaration) {
const colonIndex = declaration.indexOf(":");
if (colonIndex > 0) {
const key = declaration.slice(0, colonIndex).trim();
const value = declaration.slice(colonIndex + 1).trim();
styles.push([key, value]);
}
}
const duration = performance.now() - start;
if (duration > 20) console.log(`parseStyleAttribute: ${duration.toFixed(3)}ms, styleString length: ${styleString.length}, styles count: ${styles.length}`);
return styles;
};
/**
* Convert an attribute value to a Node prop value.
*/
const attributeValueToNodePropValue = (tag, key, value, sanitizeUrlFn) => {
if (key === "style") return parseStyleAttribute(value).reduce((styles, [styleKey, styleValue]) => {
const camelCasedKey = styleKey.replace(/(-[a-z])/g, (substr) => substr[1].toUpperCase());
styles[camelCasedKey] = sanitizeUrlFn(styleValue, tag, styleKey);
return styles;
}, {});
else if (ATTRIBUTES_TO_SANITIZE.indexOf(key) !== -1) return sanitizeUrlFn(unescapeString(value), tag, key);
else if (value.match(INTERPOLATION_R)) value = unescapeString(value.slice(1, value.length - 1));
if (value === "true") return true;
else if (value === "false") return false;
return value;
};
/**
* Parse table alignment from a separator row.
*/
const parseTableAlignCapture = (alignCapture) => {
if (TABLE_RIGHT_ALIGN.test(alignCapture)) return "right";
else if (TABLE_CENTER_ALIGN.test(alignCapture)) return "center";
else if (TABLE_LEFT_ALIGN.test(alignCapture)) return "left";
return "left";
};
/**
* Parse table alignment row.
*/
const parseTableAlign = (source) => {
return source.replace(TABLE_TRIM_PIPES, "").split("|").map(parseTableAlignCapture);
};
/**
* Parse a single table row.
*/
const parseTableRow = (source, parse, state, tableOutput) => {
const start = performance.now();
const prevInTable = state.inTable;
state.inTable = true;
const cells = [[]];
let acc = "";
const flush = () => {
if (!acc) return;
const cell = cells[cells.length - 1];
cell.push.apply(cell, parse(acc, state));
acc = "";
};
source.trim().split(/(`[^`]*`|\\\||\|)/).filter(Boolean).forEach((fragment, i, arr) => {
if (fragment.trim() === "|") {
flush();
if (tableOutput) {
if (i !== 0 && i !== arr.length - 1) cells.push([]);
return;
}
}
acc += fragment;
});
flush();
state.inTable = prevInTable;
const duration = performance.now() - start;
if (duration > 20) console.log(`parseTableRow: ${duration.toFixed(3)}ms, source length: ${source.length}, cells count: ${cells.length}`);
return cells;
};
/**
* Parse table cells (multiple rows).
*/
const parseTableCells = (source, parse, state) => {
const start = performance.now();
const rowsText = source.trim().split("\n");
const result = rowsText.map((rowText) => parseTableRow(rowText, parse, state, true));
const duration = performance.now() - start;
if (duration > 20) console.log(`parseTableCells: ${duration.toFixed(3)}ms, source length: ${source.length}, rows count: ${rowsText.length}`);
return result;
};
/**
* Check if a rule qualifies for the current source and state.
*/
const qualifies = (source, state, qualify) => {
if (Array.isArray(qualify)) {
for (let i = 0; i < qualify.length; i++) if (startsWith(source, qualify[i])) return true;
return false;
}
return qualify(source, state);
};
/**
* Marks a matcher function as eligible for being run inside an inline context.
*/
const allowInline = (fn) => {
fn.inline = 1;
return fn;
};
/**
* Creates a match function for an inline scoped element from a regex.
*/
const inlineRegex = (regex) => allowInline((source, state) => {
if (state.inline) return regex.exec(source);
else return null;
});
/**
* Creates a match function for inline elements except links.
*/
const simpleInlineRegex = (regex) => allowInline((source, state) => {
if (state.inline || state.simple) return regex.exec(source);
else return null;
});
/**
* Creates a match function for a block scoped element from a regex.
*/
const blockRegex = (regex) => (source, state) => {
if (state.inline || state.simple) return null;
else return regex.exec(source);
};
/**
* Creates a match function from a regex, ignoring block/inline scope.
*/
const anyScopeRegex = (fn) => allowInline((source, state) => {
if (typeof fn === "function") return fn(source, state);
return fn.exec(source);
});
/**
* Parse inline content (including links).
*/
const parseInline = (parse, children, state) => {
const start = performance.now();
const isCurrentlyInline = state.inline ?? false;
const isCurrentlySimple = state.simple ?? false;
state.inline = true;
state.simple = true;
const result = parse(children, state);
state.inline = isCurrentlyInline;
state.simple = isCurrentlySimple;
const duration = performance.now() - start;
if (duration > 20) console.log(`parseInline: ${duration.toFixed(3)}ms, children length: ${children.length}, result count: ${result.length}`);
return result;
};
/**
* Parse simple inline content (no links).
*/
const parseSimpleInline = (parse, children, state) => {
const start = performance.now();
const isCurrentlyInline = state.inline ?? false;
const isCurrentlySimple = state.simple ?? false;
state.inline = false;
state.simple = true;
const result = parse(children, state);
state.inline = isCurrentlyInline;
state.simple = isCurrentlySimple;
const duration = performance.now() - start;
if (duration > 20) console.log(`parseSimpleInline: ${duration.toFixed(3)}ms, children length: ${children.length}, result count: ${result.length}`);
return result;
};
/**
* Parse block content.
*/
const parseBlock = (parse, children, state = {}) => {
const start = performance.now();
const isCurrentlyInline = state.inline || false;
state.inline = false;
const normalizedChildren = trimEnd(children);
const result = parse(/\n\n$/.test(normalizedChildren) === false ? normalizedChildren.endsWith("\n") ? `${normalizedChildren}\n` : `${normalizedChildren}\n\n` : normalizedChildren, state);
state.inline = isCurrentlyInline;
const duration = performance.now() - start;
if (duration > 20) console.log(`parseBlock: ${duration.toFixed(3)}ms, children length: ${children.length}, result count: ${result.length}`);
return result;
};
/**
* Helper to parse capture group 2 as inline content.
*/
const parseCaptureInline = (capture, parse, state) => {
return { children: parseInline(parse, capture[2], state) };
};
/**
* Helper that captures nothing (empty object).
*/
const captureNothing = () => ({});
/**
* Helper that renders nothing (null).
*/
const renderNothing = () => null;
/**
* Check if any regex in a list matches the input.
*/
const some = (regexes, input) => {
for (let i = 0; i < regexes.length; i++) if (regexes[i].test(input)) return true;
return false;
};
//#endregion
export { allowInline, anyScopeRegex, attributeValueToNodePropValue, blockRegex, captureNothing, cx, get, inlineRegex, normalizeAttributeKey, normalizeWhitespace, parseBlock, parseCaptureInline, parseInline, parseSimpleInline, parseStyleAttribute, parseTableAlign, parseTableAlignCapture, parseTableCells, parseTableRow, qualifies, renderNothing, sanitizer, simpleInlineRegex, slugify, some, startsWith, trimEnd, trimLeadingWhitespaceOutsideFences, unescapeString, unquote };
//# sourceMappingURL=utils.mjs.map