quikchat 1.2.4 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/build-manifest.json +73 -80
- package/dist/quikchat-md-full.cjs.js +536 -255
- package/dist/quikchat-md-full.cjs.js.map +1 -1
- package/dist/quikchat-md-full.cjs.min.js +3 -3
- package/dist/quikchat-md-full.cjs.min.js.map +1 -1
- package/dist/quikchat-md-full.esm.js +536 -255
- package/dist/quikchat-md-full.esm.js.map +1 -1
- package/dist/quikchat-md-full.esm.min.js +3 -3
- package/dist/quikchat-md-full.esm.min.js.map +1 -1
- package/dist/quikchat-md-full.umd.js +536 -255
- package/dist/quikchat-md-full.umd.js.map +1 -1
- package/dist/quikchat-md-full.umd.min.js +3 -3
- package/dist/quikchat-md-full.umd.min.js.map +1 -1
- package/dist/quikchat-md.cjs.js +520 -245
- package/dist/quikchat-md.cjs.js.map +1 -1
- package/dist/quikchat-md.cjs.min.js +3 -3
- package/dist/quikchat-md.cjs.min.js.map +1 -1
- package/dist/quikchat-md.esm.js +520 -245
- package/dist/quikchat-md.esm.js.map +1 -1
- package/dist/quikchat-md.esm.min.js +3 -3
- package/dist/quikchat-md.esm.min.js.map +1 -1
- package/dist/quikchat-md.umd.js +520 -245
- package/dist/quikchat-md.umd.js.map +1 -1
- package/dist/quikchat-md.umd.min.js +3 -3
- package/dist/quikchat-md.umd.min.js.map +1 -1
- package/dist/quikchat.cjs.js +2 -2
- package/dist/quikchat.cjs.js.map +1 -1
- package/dist/quikchat.cjs.min.js +1 -1
- package/dist/quikchat.cjs.min.js.map +1 -1
- package/dist/quikchat.css +351 -120
- package/dist/quikchat.esm.js +2 -2
- package/dist/quikchat.esm.js.map +1 -1
- package/dist/quikchat.esm.min.js +1 -1
- package/dist/quikchat.esm.min.js.map +1 -1
- package/dist/quikchat.min.css +1 -1
- package/dist/quikchat.umd.js +2 -2
- package/dist/quikchat.umd.js.map +1 -1
- package/dist/quikchat.umd.min.js +1 -1
- package/dist/quikchat.umd.min.js.map +1 -1
- package/package.json +6 -5
package/dist/quikchat-md.cjs.js
CHANGED
|
@@ -432,7 +432,7 @@ var quikchat = /*#__PURE__*/function () {
|
|
|
432
432
|
value: function _autoGrowTextarea() {
|
|
433
433
|
var el = this._textEntry;
|
|
434
434
|
el.style.height = 'auto';
|
|
435
|
-
var maxHeight =
|
|
435
|
+
var maxHeight = 120;
|
|
436
436
|
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
|
437
437
|
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
|
438
438
|
}
|
|
@@ -900,7 +900,7 @@ var quikchat = /*#__PURE__*/function () {
|
|
|
900
900
|
key: "version",
|
|
901
901
|
value: function version() {
|
|
902
902
|
return {
|
|
903
|
-
"version": "1.2.
|
|
903
|
+
"version": "1.2.6",
|
|
904
904
|
"license": "BSD-2",
|
|
905
905
|
"url": "https://github/deftio/quikchat"
|
|
906
906
|
};
|
|
@@ -960,35 +960,140 @@ var quikchat = /*#__PURE__*/function () {
|
|
|
960
960
|
|
|
961
961
|
/**
|
|
962
962
|
* quikdown - Lightweight Markdown Parser
|
|
963
|
-
* @version 1.
|
|
963
|
+
* @version 1.2.10
|
|
964
964
|
* @license BSD-2-Clause
|
|
965
965
|
* @copyright DeftIO 2025
|
|
966
966
|
*/
|
|
967
967
|
/**
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
968
|
+
* quikdown_classify — Shared line-classification utilities
|
|
969
|
+
* ═════════════════════════════════════════════════════════
|
|
970
|
+
*
|
|
971
|
+
* Pure functions for classifying markdown lines. Used by both the main
|
|
972
|
+
* parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
|
|
973
|
+
* lives in one place.
|
|
974
|
+
*
|
|
975
|
+
* All functions operate on a **trimmed** line (caller must trim).
|
|
976
|
+
* None use regexes with nested quantifiers — every check is either a
|
|
977
|
+
* simple regex or a linear scan, so there is zero ReDoS risk.
|
|
978
978
|
*/
|
|
979
979
|
|
|
980
|
-
// Version will be injected at build time
|
|
981
|
-
const quikdownVersion = '1.1.1';
|
|
982
980
|
|
|
983
|
-
|
|
981
|
+
/**
|
|
982
|
+
* Dash-only HR check — exact parity with the main parser's original
|
|
983
|
+
* regex `/^---+\s*$/`. Only matches lines of three or more dashes
|
|
984
|
+
* with optional trailing whitespace (no interspersed spaces).
|
|
985
|
+
*
|
|
986
|
+
* @param {string} trimmed The line, already trimmed
|
|
987
|
+
* @returns {boolean}
|
|
988
|
+
*/
|
|
989
|
+
function isDashHRLine(trimmed) {
|
|
990
|
+
if (trimmed.length < 3) return false;
|
|
991
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
992
|
+
const ch = trimmed[i];
|
|
993
|
+
if (ch === '-') continue;
|
|
994
|
+
// Allow trailing whitespace only
|
|
995
|
+
if (ch === ' ' || ch === '\t') {
|
|
996
|
+
for (let j = i + 1; j < trimmed.length; j++) {
|
|
997
|
+
if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
|
|
998
|
+
}
|
|
999
|
+
return i >= 3; // at least 3 dashes before whitespace
|
|
1000
|
+
}
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
return true; // all dashes
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* quikdown — A compact, scanner-based markdown parser
|
|
1008
|
+
* ════════════════════════════════════════════════════
|
|
1009
|
+
*
|
|
1010
|
+
* Architecture overview (v1.2.8 — lexer rewrite)
|
|
1011
|
+
* ───────────────────────────────────────────────
|
|
1012
|
+
* Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
|
|
1013
|
+
* type (headings, blockquotes, HR, lists, tables) and each inline format
|
|
1014
|
+
* (bold, italic, links, …) was handled by its own global regex applied
|
|
1015
|
+
* sequentially to the full document string. That worked but made the code
|
|
1016
|
+
* hard to extend and debug — a new construct meant adding another regex
|
|
1017
|
+
* pass, and ordering bugs between passes were subtle.
|
|
1018
|
+
*
|
|
1019
|
+
* Starting in v1.2.8 the parser uses a **line-scanning** approach for
|
|
1020
|
+
* block detection and a **per-block inline pass** for formatting:
|
|
1021
|
+
*
|
|
1022
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
1023
|
+
* │ Phase 1 — Code Extraction │
|
|
1024
|
+
* │ Scan for fenced code blocks (``` / ~~~) and inline │
|
|
1025
|
+
* │ code spans (`…`). Replace with §CB§ / §IC§ place- │
|
|
1026
|
+
* │ holders so code content is never touched by later │
|
|
1027
|
+
* │ phases. │
|
|
1028
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1029
|
+
* │ Phase 2 — HTML Escaping │
|
|
1030
|
+
* │ Escape &, <, >, ", ' in the remaining text to prevent │
|
|
1031
|
+
* │ XSS. (Skipped when allow_unsafe_html is true.) │
|
|
1032
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1033
|
+
* │ Phase 3 — Block Scanning │
|
|
1034
|
+
* │ Walk the text **line by line**. At each line, the │
|
|
1035
|
+
* │ scanner checks (in order): │
|
|
1036
|
+
* │ • table rows (|) │
|
|
1037
|
+
* │ • headings (#) │
|
|
1038
|
+
* │ • HR (---) │
|
|
1039
|
+
* │ • blockquotes (>) │
|
|
1040
|
+
* │ • list items (-, *, +, 1.) │
|
|
1041
|
+
* │ • code-block placeholder (§CB…§) │
|
|
1042
|
+
* │ • paragraph text (everything else) │
|
|
1043
|
+
* │ │
|
|
1044
|
+
* │ Block text is run through the **inline formatter** │
|
|
1045
|
+
* │ which handles bold, italic, strikethrough, links, │
|
|
1046
|
+
* │ images, and autolinks. │
|
|
1047
|
+
* │ │
|
|
1048
|
+
* │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
|
|
1049
|
+
* │ (single \n → <br>) are handled here too. │
|
|
1050
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1051
|
+
* │ Phase 4 — Code Restoration │
|
|
1052
|
+
* │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
|
|
1053
|
+
* │ / <code> HTML, applying the fence_plugin if present. │
|
|
1054
|
+
* └─────────────────────────────────────────────────────────┘
|
|
1055
|
+
*
|
|
1056
|
+
* Why this design?
|
|
1057
|
+
* • Single pass over lines for block identification — no re-scanning.
|
|
1058
|
+
* • Each block type is a clearly separated branch, easy to add new ones.
|
|
1059
|
+
* • Inline formatting is confined to block text — can't accidentally
|
|
1060
|
+
* match across block boundaries or inside HTML tags.
|
|
1061
|
+
* • Code extraction still uses a simple regex (it's one pattern, not a
|
|
1062
|
+
* chain) because the §-placeholder approach is proven and simple.
|
|
1063
|
+
*
|
|
1064
|
+
* @param {string} markdown The markdown source text
|
|
1065
|
+
* @param {Object} options Configuration (see below)
|
|
1066
|
+
* @returns {string} Rendered HTML
|
|
1067
|
+
*/
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1071
|
+
// Constants
|
|
1072
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1073
|
+
|
|
1074
|
+
/** Build-time version stamp (injected by tools/updateVersion) */
|
|
1075
|
+
const quikdownVersion = '1.2.10';
|
|
1076
|
+
|
|
1077
|
+
/** CSS class prefix used for all generated elements */
|
|
984
1078
|
const CLASS_PREFIX = 'quikdown-';
|
|
985
|
-
const PLACEHOLDER_CB = '§CB';
|
|
986
|
-
const PLACEHOLDER_IC = '§IC';
|
|
987
1079
|
|
|
988
|
-
|
|
1080
|
+
/** Placeholder sigils — chosen to be extremely unlikely in real text */
|
|
1081
|
+
const PLACEHOLDER_CB = '§CB'; // fenced code blocks
|
|
1082
|
+
const PLACEHOLDER_IC = '§IC'; // inline code spans
|
|
1083
|
+
|
|
1084
|
+
/** HTML entity escape map */
|
|
989
1085
|
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
990
1086
|
|
|
991
|
-
//
|
|
1087
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1088
|
+
// Style definitions
|
|
1089
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Inline styles for every element quikdown can emit.
|
|
1093
|
+
* When `inline_styles: true` these are injected as style="…" attributes.
|
|
1094
|
+
* When `inline_styles: false` (default) we use class="quikdown-<tag>"
|
|
1095
|
+
* and these same values are emitted by `quikdown.emitStyles()`.
|
|
1096
|
+
*/
|
|
992
1097
|
const QUIKDOWN_STYLES = {
|
|
993
1098
|
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
994
1099
|
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
@@ -1011,30 +1116,41 @@ const QUIKDOWN_STYLES = {
|
|
|
1011
1116
|
ul: 'margin:.5em 0;padding-left:2em',
|
|
1012
1117
|
ol: 'margin:.5em 0;padding-left:2em',
|
|
1013
1118
|
li: 'margin:.25em 0',
|
|
1014
|
-
// Task list specific styles
|
|
1015
1119
|
'task-item': 'list-style:none',
|
|
1016
1120
|
'task-checkbox': 'margin-right:.5em'
|
|
1017
1121
|
};
|
|
1018
1122
|
|
|
1019
|
-
//
|
|
1123
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1124
|
+
// Attribute factory
|
|
1125
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Creates a `getAttr(tag, additionalStyle?)` helper that returns
|
|
1129
|
+
* either a class="…" or style="…" attribute string depending on mode.
|
|
1130
|
+
*
|
|
1131
|
+
* @param {boolean} inline_styles True → emit style="…"; false → class="…"
|
|
1132
|
+
* @param {Object} styles The QUIKDOWN_STYLES map
|
|
1133
|
+
* @returns {Function}
|
|
1134
|
+
*/
|
|
1020
1135
|
function createGetAttr(inline_styles, styles) {
|
|
1021
1136
|
return function(tag, additionalStyle = '') {
|
|
1022
1137
|
if (inline_styles) {
|
|
1023
1138
|
let style = styles[tag];
|
|
1024
1139
|
if (!style && !additionalStyle) return '';
|
|
1025
|
-
|
|
1026
|
-
//
|
|
1140
|
+
|
|
1141
|
+
// When adding alignment that conflicts with the tag's default,
|
|
1142
|
+
// strip the default text-align first.
|
|
1027
1143
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
1028
1144
|
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
1145
|
+
/* istanbul ignore next */
|
|
1029
1146
|
if (style && !style.endsWith(';')) style += ';';
|
|
1030
1147
|
}
|
|
1031
|
-
|
|
1148
|
+
|
|
1032
1149
|
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
1033
1150
|
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
1034
1151
|
return ` style="${fullStyle}"`;
|
|
1035
1152
|
} else {
|
|
1036
1153
|
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
1037
|
-
// Apply inline styles for alignment even when using CSS classes
|
|
1038
1154
|
if (additionalStyle) {
|
|
1039
1155
|
return `${classAttr} style="${additionalStyle}"`;
|
|
1040
1156
|
}
|
|
@@ -1043,69 +1159,84 @@ function createGetAttr(inline_styles, styles) {
|
|
|
1043
1159
|
};
|
|
1044
1160
|
}
|
|
1045
1161
|
|
|
1162
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1163
|
+
// Main parser function
|
|
1164
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1165
|
+
|
|
1046
1166
|
function quikdown(markdown, options = {}) {
|
|
1167
|
+
// ── Guard: only process non-empty strings ──
|
|
1047
1168
|
if (!markdown || typeof markdown !== 'string') {
|
|
1048
1169
|
return '';
|
|
1049
1170
|
}
|
|
1050
|
-
|
|
1051
|
-
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
|
|
1052
|
-
const styles = QUIKDOWN_STYLES; // Use module-level styles
|
|
1053
|
-
const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
|
|
1054
1171
|
|
|
1055
|
-
//
|
|
1172
|
+
// ── Unpack options ──
|
|
1173
|
+
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
|
|
1174
|
+
const styles = QUIKDOWN_STYLES;
|
|
1175
|
+
const getAttr = createGetAttr(inline_styles, styles);
|
|
1176
|
+
|
|
1177
|
+
// ── Helpers (closed over options) ──
|
|
1178
|
+
|
|
1179
|
+
/** Escape the five HTML-special characters. */
|
|
1056
1180
|
function escapeHtml(text) {
|
|
1057
1181
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
1058
1182
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Bidirectional marker helper.
|
|
1186
|
+
* When bidirectional mode is on, returns ` data-qd="…"`.
|
|
1187
|
+
* The non-bidirectional branch is a trivial no-op arrow; it is
|
|
1188
|
+
* exercised in the core bundle but never in quikdown_bd.
|
|
1189
|
+
*/
|
|
1190
|
+
/* istanbul ignore next - trivial no-op fallback */
|
|
1061
1191
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
1062
|
-
|
|
1063
|
-
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
|
|
1195
|
+
* Returns '#' for blocked URLs.
|
|
1196
|
+
*/
|
|
1064
1197
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
1065
1198
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
1066
1199
|
if (!url) return '';
|
|
1067
|
-
|
|
1068
|
-
// If unsafe URLs are explicitly allowed, return as-is
|
|
1069
1200
|
if (allowUnsafe) return url;
|
|
1070
|
-
|
|
1201
|
+
|
|
1071
1202
|
const trimmedUrl = url.trim();
|
|
1072
1203
|
const lowerUrl = trimmedUrl.toLowerCase();
|
|
1073
|
-
|
|
1074
|
-
// Block dangerous protocols
|
|
1075
1204
|
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
|
|
1076
|
-
|
|
1205
|
+
|
|
1077
1206
|
for (const protocol of dangerousProtocols) {
|
|
1078
1207
|
if (lowerUrl.startsWith(protocol)) {
|
|
1079
|
-
// Exception: Allow data:image/* for images
|
|
1080
1208
|
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
|
|
1081
1209
|
return trimmedUrl;
|
|
1082
1210
|
}
|
|
1083
|
-
// Return safe empty link for dangerous protocols
|
|
1084
1211
|
return '#';
|
|
1085
1212
|
}
|
|
1086
1213
|
}
|
|
1087
|
-
|
|
1088
1214
|
return trimmedUrl;
|
|
1089
1215
|
}
|
|
1090
1216
|
|
|
1091
|
-
//
|
|
1217
|
+
// ────────────────────────────────────────────────────────────────
|
|
1218
|
+
// Phase 1 — Code Extraction
|
|
1219
|
+
// ────────────────────────────────────────────────────────────────
|
|
1220
|
+
// Why extract code first? Fenced blocks and inline code spans can
|
|
1221
|
+
// contain markdown-like characters (*, _, #, |, etc.) that must NOT
|
|
1222
|
+
// be interpreted as formatting. By pulling them out and replacing
|
|
1223
|
+
// with unique placeholders, the rest of the pipeline never sees them.
|
|
1224
|
+
|
|
1092
1225
|
let html = markdown;
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
// Fence must be at start of line
|
|
1226
|
+
const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
|
|
1227
|
+
const inlineCodes = []; // Array of escaped-HTML strings
|
|
1228
|
+
|
|
1229
|
+
// ── Fenced code blocks ──
|
|
1230
|
+
// Matches paired fences: ``` with ``` and ~~~ with ~~~.
|
|
1231
|
+
// The fence must start at column 0 of a line (^ with /m flag).
|
|
1232
|
+
// Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
|
|
1101
1233
|
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
|
|
1102
1234
|
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
|
|
1103
|
-
|
|
1104
|
-
// Trim the language specification
|
|
1105
1235
|
const langTrimmed = lang ? lang.trim() : '';
|
|
1106
|
-
|
|
1107
|
-
// If custom fence plugin is provided, use it (v1.1.0: object format required)
|
|
1236
|
+
|
|
1108
1237
|
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
|
|
1238
|
+
// Custom plugin — store raw code (un-escaped) so the plugin
|
|
1239
|
+
// receives the original source.
|
|
1109
1240
|
codeBlocks.push({
|
|
1110
1241
|
lang: langTrimmed,
|
|
1111
1242
|
code: code.trimEnd(),
|
|
@@ -1114,6 +1245,7 @@ function quikdown(markdown, options = {}) {
|
|
|
1114
1245
|
hasReverse: !!fence_plugin.reverse
|
|
1115
1246
|
});
|
|
1116
1247
|
} else {
|
|
1248
|
+
// Default — pre-escape the code for safe HTML output.
|
|
1117
1249
|
codeBlocks.push({
|
|
1118
1250
|
lang: langTrimmed,
|
|
1119
1251
|
code: escapeHtml(code.trimEnd()),
|
|
@@ -1123,66 +1255,97 @@ function quikdown(markdown, options = {}) {
|
|
|
1123
1255
|
}
|
|
1124
1256
|
return placeholder;
|
|
1125
1257
|
});
|
|
1126
|
-
|
|
1127
|
-
//
|
|
1258
|
+
|
|
1259
|
+
// ── Inline code spans ──
|
|
1260
|
+
// Matches a single backtick pair: `content`.
|
|
1261
|
+
// Content is captured and HTML-escaped immediately.
|
|
1128
1262
|
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
|
1129
1263
|
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
|
|
1130
1264
|
inlineCodes.push(escapeHtml(code));
|
|
1131
1265
|
return placeholder;
|
|
1132
1266
|
});
|
|
1133
|
-
|
|
1134
|
-
//
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
//
|
|
1138
|
-
|
|
1139
|
-
//
|
|
1267
|
+
|
|
1268
|
+
// ────────────────────────────────────────────────────────────────
|
|
1269
|
+
// Phase 2 — HTML Escaping
|
|
1270
|
+
// ────────────────────────────────────────────────────────────────
|
|
1271
|
+
// All remaining text (everything except code placeholders) is escaped
|
|
1272
|
+
// to prevent XSS. The `allow_unsafe_html` option skips this for
|
|
1273
|
+
// trusted pipelines that intentionally embed raw HTML.
|
|
1274
|
+
|
|
1275
|
+
if (!allow_unsafe_html) {
|
|
1276
|
+
html = escapeHtml(html);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// ────────────────────────────────────────────────────────────────
|
|
1280
|
+
// Phase 3 — Block Scanning + Inline Formatting + Paragraphs
|
|
1281
|
+
// ────────────────────────────────────────────────────────────────
|
|
1282
|
+
// This is the heart of the lexer rewrite. Instead of applying
|
|
1283
|
+
// 10+ global regex passes, we:
|
|
1284
|
+
// 1. Process tables (line walker — tables need multi-line lookahead)
|
|
1285
|
+
// 2. Scan remaining lines for headings, HR, blockquotes
|
|
1286
|
+
// 3. Process lists (line walker — lists need indent tracking)
|
|
1287
|
+
// 4. Apply inline formatting to all text content
|
|
1288
|
+
// 5. Wrap remaining text in <p> tags
|
|
1289
|
+
//
|
|
1290
|
+
// Steps 1 and 3 are line-walkers that process the full text in a
|
|
1291
|
+
// single pass each. Step 2 replaces global regex with a per-line
|
|
1292
|
+
// scanner. Steps 4-5 are applied to the result.
|
|
1293
|
+
//
|
|
1294
|
+
// Total: 3 structured passes instead of 10+ regex passes.
|
|
1295
|
+
|
|
1296
|
+
// ── Step 1: Tables ──
|
|
1297
|
+
// Tables need multi-line lookahead (header → separator → body rows)
|
|
1298
|
+
// so they're handled by a dedicated line-walker first.
|
|
1140
1299
|
html = processTable(html, getAttr);
|
|
1141
|
-
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
//
|
|
1149
|
-
|
|
1150
|
-
// Merge consecutive blockquotes
|
|
1151
|
-
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
1152
|
-
|
|
1153
|
-
// Process horizontal rules (allow trailing spaces)
|
|
1154
|
-
html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
|
|
1155
|
-
|
|
1156
|
-
// Process lists
|
|
1300
|
+
|
|
1301
|
+
// ── Step 2: Headings, HR, Blockquotes ──
|
|
1302
|
+
// These are simple line-level constructs. We scan each line once
|
|
1303
|
+
// and replace matching lines with their HTML representation.
|
|
1304
|
+
html = scanLineBlocks(html, getAttr, dataQd);
|
|
1305
|
+
|
|
1306
|
+
// ── Step 3: Lists ──
|
|
1307
|
+
// Lists need indent-level tracking across lines, so they get their
|
|
1308
|
+
// own line-walker.
|
|
1157
1309
|
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
1158
|
-
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
//
|
|
1310
|
+
|
|
1311
|
+
// ── Step 4: Inline formatting ──
|
|
1312
|
+
// Apply bold, italic, strikethrough, images, links, and autolinks
|
|
1313
|
+
// to all text content. This runs on the output of steps 1-3, so
|
|
1314
|
+
// it sees text inside headings, blockquotes, table cells, list
|
|
1315
|
+
// items, and paragraph text.
|
|
1316
|
+
|
|
1317
|
+
// Images (must come before links —  vs [text](url))
|
|
1162
1318
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
1163
1319
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
1320
|
+
/* istanbul ignore next - bd-only branch */
|
|
1164
1321
|
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
1322
|
+
/* istanbul ignore next - bd-only branch */
|
|
1165
1323
|
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
1166
1324
|
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
1167
1325
|
});
|
|
1168
|
-
|
|
1169
|
-
// Links
|
|
1326
|
+
|
|
1327
|
+
// Links
|
|
1170
1328
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
|
|
1171
|
-
// Sanitize URL to prevent XSS
|
|
1172
1329
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
1173
1330
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
1174
1331
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
1332
|
+
/* istanbul ignore next - bd-only branch */
|
|
1175
1333
|
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
1176
1334
|
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
1177
1335
|
});
|
|
1178
|
-
|
|
1179
|
-
// Autolinks
|
|
1336
|
+
|
|
1337
|
+
// Autolinks — bare https?:// URLs become clickable <a> tags
|
|
1180
1338
|
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
|
|
1181
1339
|
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
|
|
1182
1340
|
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
|
|
1183
1341
|
});
|
|
1184
|
-
|
|
1185
|
-
//
|
|
1342
|
+
|
|
1343
|
+
// Protect rendered tags so emphasis regexes don't see attribute
|
|
1344
|
+
// values — fixes #3 (underscores in URLs interpreted as emphasis).
|
|
1345
|
+
const savedTags = [];
|
|
1346
|
+
html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });
|
|
1347
|
+
|
|
1348
|
+
// Bold, italic, strikethrough
|
|
1186
1349
|
const inlinePatterns = [
|
|
1187
1350
|
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
1188
1351
|
[/__(.+?)__/g, 'strong', '__'],
|
|
@@ -1190,60 +1353,66 @@ function quikdown(markdown, options = {}) {
|
|
|
1190
1353
|
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
1191
1354
|
[/~~(.+?)~~/g, 'del', '~~']
|
|
1192
1355
|
];
|
|
1193
|
-
|
|
1194
1356
|
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
1195
1357
|
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
1196
1358
|
});
|
|
1197
|
-
|
|
1198
|
-
//
|
|
1359
|
+
|
|
1360
|
+
// Restore protected tags
|
|
1361
|
+
html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);
|
|
1362
|
+
|
|
1363
|
+
// ── Step 5: Line breaks + paragraph wrapping ──
|
|
1199
1364
|
if (lazy_linefeeds) {
|
|
1200
|
-
// Lazy linefeeds: single
|
|
1365
|
+
// Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
|
|
1366
|
+
// • Double newlines → paragraph break
|
|
1367
|
+
// • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
|
|
1368
|
+
//
|
|
1369
|
+
// Strategy: protect block-adjacent newlines with §N§, convert
|
|
1370
|
+
// the rest, then restore.
|
|
1371
|
+
|
|
1201
1372
|
const blocks = [];
|
|
1202
1373
|
let bi = 0;
|
|
1203
|
-
|
|
1204
|
-
// Protect tables and lists
|
|
1374
|
+
|
|
1375
|
+
// Protect tables and lists from <br> injection
|
|
1205
1376
|
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
1206
1377
|
blocks[bi] = m;
|
|
1207
1378
|
return `§B${bi++}§`;
|
|
1208
1379
|
});
|
|
1209
|
-
|
|
1210
|
-
// Handle paragraphs and block elements
|
|
1380
|
+
|
|
1211
1381
|
html = html.replace(/\n\n+/g, '§P§')
|
|
1212
|
-
// After block
|
|
1382
|
+
// After block-level closing tags
|
|
1213
1383
|
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
1214
1384
|
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
1215
|
-
// Before block
|
|
1385
|
+
// Before block-level opening tags
|
|
1216
1386
|
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
1217
1387
|
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
1218
1388
|
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
1219
|
-
// Convert
|
|
1389
|
+
// Convert surviving newlines to <br>
|
|
1220
1390
|
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
1221
1391
|
// Restore
|
|
1222
1392
|
.replace(/§N§/g, '\n')
|
|
1223
1393
|
.replace(/§P§/g, '</p><p>');
|
|
1224
|
-
|
|
1394
|
+
|
|
1225
1395
|
// Restore protected blocks
|
|
1226
1396
|
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
1227
|
-
|
|
1397
|
+
|
|
1228
1398
|
html = '<p>' + html + '</p>';
|
|
1229
1399
|
} else {
|
|
1230
|
-
// Standard: two spaces
|
|
1231
|
-
html = html.replace(/
|
|
1232
|
-
|
|
1233
|
-
// Paragraphs (double newlines)
|
|
1234
|
-
// Don't add </p> after block elements (they're not in paragraphs)
|
|
1400
|
+
// Standard mode: two trailing spaces → <br>, double newline → new paragraph
|
|
1401
|
+
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
|
|
1402
|
+
|
|
1235
1403
|
html = html.replace(/\n\n+/g, (match, offset) => {
|
|
1236
|
-
// Check if we're after a block element closing tag
|
|
1237
1404
|
const before = html.substring(0, offset);
|
|
1238
1405
|
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
|
|
1239
|
-
return '<p>';
|
|
1406
|
+
return '<p>';
|
|
1240
1407
|
}
|
|
1241
|
-
return '</p><p>';
|
|
1408
|
+
return '</p><p>';
|
|
1242
1409
|
});
|
|
1243
1410
|
html = '<p>' + html + '</p>';
|
|
1244
1411
|
}
|
|
1245
|
-
|
|
1246
|
-
//
|
|
1412
|
+
|
|
1413
|
+
// ── Step 6: Cleanup ──
|
|
1414
|
+
// Remove <p> wrappers that accidentally enclose block elements.
|
|
1415
|
+
// This is simpler than trying to prevent them during wrapping.
|
|
1247
1416
|
const cleanupPatterns = [
|
|
1248
1417
|
[/<p><\/p>/g, ''],
|
|
1249
1418
|
[/<p>(<h[1-6][^>]*>)/g, '$1'],
|
|
@@ -1257,67 +1426,154 @@ function quikdown(markdown, options = {}) {
|
|
|
1257
1426
|
[/(<\/table>)<\/p>/g, '$1'],
|
|
1258
1427
|
[/<p>(<pre[^>]*>)/g, '$1'],
|
|
1259
1428
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
1260
|
-
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)
|
|
1429
|
+
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
1261
1430
|
];
|
|
1262
|
-
|
|
1263
1431
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
1264
1432
|
html = html.replace(pattern, replacement);
|
|
1265
1433
|
});
|
|
1266
|
-
|
|
1267
|
-
//
|
|
1268
|
-
// When a paragraph follows a block element, ensure it has opening <p>
|
|
1434
|
+
|
|
1435
|
+
// When a block element is followed by a newline and then text, open a <p>.
|
|
1269
1436
|
html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
|
|
1270
|
-
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1273
|
-
//
|
|
1437
|
+
|
|
1438
|
+
// ────────────────────────────────────────────────────────────────
|
|
1439
|
+
// Phase 4 — Code Restoration
|
|
1440
|
+
// ────────────────────────────────────────────────────────────────
|
|
1441
|
+
// Replace placeholders with rendered HTML. For fenced blocks this
|
|
1442
|
+
// means wrapping in <pre><code>…</code></pre> (or calling the
|
|
1443
|
+
// fence_plugin). For inline code it means <code>…</code>.
|
|
1444
|
+
|
|
1274
1445
|
codeBlocks.forEach((block, i) => {
|
|
1275
1446
|
let replacement;
|
|
1276
|
-
|
|
1447
|
+
|
|
1277
1448
|
if (block.custom && fence_plugin && fence_plugin.render) {
|
|
1278
|
-
//
|
|
1449
|
+
// Delegate to the user-provided fence plugin.
|
|
1279
1450
|
replacement = fence_plugin.render(block.code, block.lang);
|
|
1280
|
-
|
|
1281
|
-
// If plugin returns undefined, fall back to default rendering
|
|
1451
|
+
|
|
1282
1452
|
if (replacement === undefined) {
|
|
1453
|
+
// Plugin declined — fall back to default rendering.
|
|
1283
1454
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
1284
1455
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
1456
|
+
/* istanbul ignore next - bd-only branch */
|
|
1285
1457
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
1458
|
+
/* istanbul ignore next - bd-only branch */
|
|
1286
1459
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
1287
1460
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
1288
|
-
} else if (bidirectional) {
|
|
1289
|
-
//
|
|
1290
|
-
replacement = replacement.replace(/^<(\w+)/,
|
|
1461
|
+
} else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
|
|
1462
|
+
// Plugin returned HTML — inject data attributes for roundtrip.
|
|
1463
|
+
replacement = replacement.replace(/^<(\w+)/,
|
|
1291
1464
|
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
|
|
1292
1465
|
}
|
|
1293
1466
|
} else {
|
|
1294
|
-
// Default rendering
|
|
1467
|
+
// Default rendering — wrap in <pre><code>.
|
|
1295
1468
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
1296
1469
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
1470
|
+
/* istanbul ignore next - bd-only branch */
|
|
1297
1471
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
1472
|
+
/* istanbul ignore next - bd-only branch */
|
|
1298
1473
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
1299
1474
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
1300
1475
|
}
|
|
1301
|
-
|
|
1476
|
+
|
|
1302
1477
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
1303
1478
|
html = html.replace(placeholder, replacement);
|
|
1304
1479
|
});
|
|
1305
|
-
|
|
1306
|
-
// Restore inline code
|
|
1480
|
+
|
|
1481
|
+
// Restore inline code spans
|
|
1307
1482
|
inlineCodes.forEach((code, i) => {
|
|
1308
1483
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
1309
1484
|
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
1310
1485
|
});
|
|
1311
|
-
|
|
1486
|
+
|
|
1312
1487
|
return html.trim();
|
|
1313
1488
|
}
|
|
1314
1489
|
|
|
1490
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1491
|
+
// Block-level line scanner
|
|
1492
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1493
|
+
|
|
1315
1494
|
/**
|
|
1316
|
-
*
|
|
1495
|
+
* scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
|
|
1496
|
+
*
|
|
1497
|
+
* Walks the text line by line. For each line it checks (in order):
|
|
1498
|
+
* 1. Heading — starts with 1-6 '#' followed by a space
|
|
1499
|
+
* 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
|
|
1500
|
+
* 3. Blockquote — starts with '> ' (the > was already HTML-escaped)
|
|
1501
|
+
*
|
|
1502
|
+
* Lines that don't match any block pattern are passed through unchanged.
|
|
1503
|
+
*
|
|
1504
|
+
* This replaces three separate global regex passes from the pre-1.2.8
|
|
1505
|
+
* architecture with one structured scan.
|
|
1506
|
+
*
|
|
1507
|
+
* @param {string} text The document text (HTML-escaped, code extracted)
|
|
1508
|
+
* @param {Function} getAttr Attribute factory (class or style)
|
|
1509
|
+
* @param {Function} dataQd Bidirectional marker factory
|
|
1510
|
+
* @returns {string} Text with block-level elements rendered
|
|
1511
|
+
*/
|
|
1512
|
+
function scanLineBlocks(text, getAttr, dataQd) {
|
|
1513
|
+
const lines = text.split('\n');
|
|
1514
|
+
const result = [];
|
|
1515
|
+
let i = 0;
|
|
1516
|
+
|
|
1517
|
+
while (i < lines.length) {
|
|
1518
|
+
const line = lines[i];
|
|
1519
|
+
|
|
1520
|
+
// ── Heading ──
|
|
1521
|
+
// Count leading '#' characters. Valid heading: 1-6 hashes then a space.
|
|
1522
|
+
// Example: "## Hello World ##" → <h2>Hello World</h2>
|
|
1523
|
+
let hashCount = 0;
|
|
1524
|
+
while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
|
|
1525
|
+
hashCount++;
|
|
1526
|
+
}
|
|
1527
|
+
if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
|
|
1528
|
+
// Extract content after "# " and strip trailing hashes
|
|
1529
|
+
const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
|
|
1530
|
+
const tag = 'h' + hashCount;
|
|
1531
|
+
result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
|
|
1532
|
+
i++;
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// ── Horizontal Rule ──
|
|
1537
|
+
// Three or more dashes, optional trailing whitespace, nothing else.
|
|
1538
|
+
if (isDashHRLine(line)) {
|
|
1539
|
+
result.push(`<hr${getAttr('hr')}>`);
|
|
1540
|
+
i++;
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// ── Blockquote ──
|
|
1545
|
+
// After Phase 2, the '>' character has been escaped to '>'.
|
|
1546
|
+
// Pattern: "> content" or merged consecutive blockquotes.
|
|
1547
|
+
if (/^>\s+/.test(line)) {
|
|
1548
|
+
result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^>\s+/, '')}</blockquote>`);
|
|
1549
|
+
i++;
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// ── Pass-through ──
|
|
1554
|
+
result.push(line);
|
|
1555
|
+
i++;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Merge consecutive blockquotes into a single element.
|
|
1559
|
+
// <blockquote>A</blockquote>\n<blockquote>B</blockquote>
|
|
1560
|
+
// → <blockquote>A\nB</blockquote>
|
|
1561
|
+
let joined = result.join('\n');
|
|
1562
|
+
joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
1563
|
+
return joined;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1567
|
+
// Table processing (line walker)
|
|
1568
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Inline markdown formatter for table cells.
|
|
1572
|
+
* Handles bold, italic, strikethrough, and code within cell text.
|
|
1573
|
+
* Links / images / autolinks are handled by the global inline pass
|
|
1574
|
+
* (Phase 3 Step 4) which runs after table processing.
|
|
1317
1575
|
*/
|
|
1318
1576
|
function processInlineMarkdown(text, getAttr) {
|
|
1319
|
-
|
|
1320
|
-
// Process inline formatting patterns
|
|
1321
1577
|
const patterns = [
|
|
1322
1578
|
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
1323
1579
|
[/__(.+?)__/g, 'strong'],
|
|
@@ -1326,27 +1582,32 @@ function processInlineMarkdown(text, getAttr) {
|
|
|
1326
1582
|
[/~~(.+?)~~/g, 'del'],
|
|
1327
1583
|
[/`([^`]+)`/g, 'code']
|
|
1328
1584
|
];
|
|
1329
|
-
|
|
1330
1585
|
patterns.forEach(([pattern, tag]) => {
|
|
1331
1586
|
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
1332
1587
|
});
|
|
1333
|
-
|
|
1334
1588
|
return text;
|
|
1335
1589
|
}
|
|
1336
1590
|
|
|
1337
1591
|
/**
|
|
1338
|
-
*
|
|
1592
|
+
* processTable — line walker for markdown tables
|
|
1593
|
+
*
|
|
1594
|
+
* Walks through lines looking for runs of pipe-containing lines.
|
|
1595
|
+
* Each run is validated (must contain a separator row: |---|---|)
|
|
1596
|
+
* and rendered as an HTML <table>. Invalid runs are restored as-is.
|
|
1597
|
+
*
|
|
1598
|
+
* @param {string} text Full document text
|
|
1599
|
+
* @param {Function} getAttr Attribute factory
|
|
1600
|
+
* @returns {string} Text with tables rendered
|
|
1339
1601
|
*/
|
|
1340
1602
|
function processTable(text, getAttr) {
|
|
1341
1603
|
const lines = text.split('\n');
|
|
1342
1604
|
const result = [];
|
|
1343
1605
|
let inTable = false;
|
|
1344
1606
|
let tableLines = [];
|
|
1345
|
-
|
|
1607
|
+
|
|
1346
1608
|
for (let i = 0; i < lines.length; i++) {
|
|
1347
1609
|
const line = lines[i].trim();
|
|
1348
|
-
|
|
1349
|
-
// Check if this line looks like a table row (with or without trailing |)
|
|
1610
|
+
|
|
1350
1611
|
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
|
|
1351
1612
|
if (!inTable) {
|
|
1352
1613
|
inTable = true;
|
|
@@ -1354,14 +1615,11 @@ function processTable(text, getAttr) {
|
|
|
1354
1615
|
}
|
|
1355
1616
|
tableLines.push(line);
|
|
1356
1617
|
} else {
|
|
1357
|
-
// Not a table line
|
|
1358
1618
|
if (inTable) {
|
|
1359
|
-
// Process the accumulated table
|
|
1360
1619
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
1361
1620
|
if (tableHtml) {
|
|
1362
1621
|
result.push(tableHtml);
|
|
1363
1622
|
} else {
|
|
1364
|
-
// Not a valid table, restore original lines
|
|
1365
1623
|
result.push(...tableLines);
|
|
1366
1624
|
}
|
|
1367
1625
|
inTable = false;
|
|
@@ -1370,8 +1628,8 @@ function processTable(text, getAttr) {
|
|
|
1370
1628
|
result.push(lines[i]);
|
|
1371
1629
|
}
|
|
1372
1630
|
}
|
|
1373
|
-
|
|
1374
|
-
// Handle table at end of
|
|
1631
|
+
|
|
1632
|
+
// Handle table at end of document
|
|
1375
1633
|
if (inTable && tableLines.length > 0) {
|
|
1376
1634
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
1377
1635
|
if (tableHtml) {
|
|
@@ -1380,35 +1638,35 @@ function processTable(text, getAttr) {
|
|
|
1380
1638
|
result.push(...tableLines);
|
|
1381
1639
|
}
|
|
1382
1640
|
}
|
|
1383
|
-
|
|
1641
|
+
|
|
1384
1642
|
return result.join('\n');
|
|
1385
1643
|
}
|
|
1386
1644
|
|
|
1387
1645
|
/**
|
|
1388
|
-
*
|
|
1646
|
+
* buildTable — validate and render a table from accumulated lines
|
|
1647
|
+
*
|
|
1648
|
+
* @param {string[]} lines Array of pipe-containing lines
|
|
1649
|
+
* @param {Function} getAttr Attribute factory
|
|
1650
|
+
* @returns {string|null} HTML table string, or null if invalid
|
|
1389
1651
|
*/
|
|
1390
1652
|
function buildTable(lines, getAttr) {
|
|
1391
|
-
|
|
1392
1653
|
if (lines.length < 2) return null;
|
|
1393
|
-
|
|
1394
|
-
//
|
|
1654
|
+
|
|
1655
|
+
// Find the separator row (---|---|)
|
|
1395
1656
|
let separatorIndex = -1;
|
|
1396
1657
|
for (let i = 1; i < lines.length; i++) {
|
|
1397
|
-
// Support separator with or without leading/trailing pipes
|
|
1398
1658
|
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
|
|
1399
1659
|
separatorIndex = i;
|
|
1400
1660
|
break;
|
|
1401
1661
|
}
|
|
1402
1662
|
}
|
|
1403
|
-
|
|
1404
1663
|
if (separatorIndex === -1) return null;
|
|
1405
|
-
|
|
1664
|
+
|
|
1406
1665
|
const headerLines = lines.slice(0, separatorIndex);
|
|
1407
1666
|
const bodyLines = lines.slice(separatorIndex + 1);
|
|
1408
|
-
|
|
1409
|
-
// Parse alignment from separator
|
|
1667
|
+
|
|
1668
|
+
// Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
|
|
1410
1669
|
const separator = lines[separatorIndex];
|
|
1411
|
-
// Handle pipes at start/end or not
|
|
1412
1670
|
const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1413
1671
|
const alignments = separatorCells.map(cell => {
|
|
1414
1672
|
const trimmed = cell.trim();
|
|
@@ -1416,31 +1674,28 @@ function buildTable(lines, getAttr) {
|
|
|
1416
1674
|
if (trimmed.endsWith(':')) return 'right';
|
|
1417
1675
|
return 'left';
|
|
1418
1676
|
});
|
|
1419
|
-
|
|
1677
|
+
|
|
1420
1678
|
let html = `<table${getAttr('table')}>\n`;
|
|
1421
|
-
|
|
1422
|
-
//
|
|
1423
|
-
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
1679
|
+
|
|
1680
|
+
// Header
|
|
1424
1681
|
html += `<thead${getAttr('thead')}>\n`;
|
|
1425
1682
|
headerLines.forEach(line => {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
html += '</tr>\n';
|
|
1683
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
1684
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1685
|
+
cells.forEach((cell, i) => {
|
|
1686
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
1687
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
1688
|
+
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
1689
|
+
});
|
|
1690
|
+
html += '</tr>\n';
|
|
1435
1691
|
});
|
|
1436
1692
|
html += '</thead>\n';
|
|
1437
|
-
|
|
1438
|
-
//
|
|
1693
|
+
|
|
1694
|
+
// Body
|
|
1439
1695
|
if (bodyLines.length > 0) {
|
|
1440
1696
|
html += `<tbody${getAttr('tbody')}>\n`;
|
|
1441
1697
|
bodyLines.forEach(line => {
|
|
1442
1698
|
html += `<tr${getAttr('tr')}>\n`;
|
|
1443
|
-
// Handle pipes at start/end or not
|
|
1444
1699
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1445
1700
|
cells.forEach((cell, i) => {
|
|
1446
1701
|
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
@@ -1451,61 +1706,81 @@ function buildTable(lines, getAttr) {
|
|
|
1451
1706
|
});
|
|
1452
1707
|
html += '</tbody>\n';
|
|
1453
1708
|
}
|
|
1454
|
-
|
|
1709
|
+
|
|
1455
1710
|
html += '</table>';
|
|
1456
1711
|
return html;
|
|
1457
1712
|
}
|
|
1458
1713
|
|
|
1714
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1715
|
+
// List processing (line walker)
|
|
1716
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1717
|
+
|
|
1459
1718
|
/**
|
|
1460
|
-
*
|
|
1719
|
+
* processLists — line walker for ordered, unordered, and task lists
|
|
1720
|
+
*
|
|
1721
|
+
* Scans each line for list markers (-, *, +, 1., 2., etc.) with
|
|
1722
|
+
* optional leading indentation for nesting. Non-list lines close
|
|
1723
|
+
* any open lists and pass through unchanged.
|
|
1724
|
+
*
|
|
1725
|
+
* Task lists (- [ ] / - [x]) are detected and rendered with
|
|
1726
|
+
* checkbox inputs.
|
|
1727
|
+
*
|
|
1728
|
+
* @param {string} text Full document text
|
|
1729
|
+
* @param {Function} getAttr Attribute factory
|
|
1730
|
+
* @param {boolean} inline_styles Whether to use inline styles
|
|
1731
|
+
* @param {boolean} bidirectional Whether to add data-qd markers
|
|
1732
|
+
* @returns {string} Text with lists rendered
|
|
1461
1733
|
*/
|
|
1462
1734
|
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
1463
|
-
|
|
1464
1735
|
const lines = text.split('\n');
|
|
1465
1736
|
const result = [];
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
// Helper to escape HTML for data-qd attributes
|
|
1469
|
-
|
|
1737
|
+
const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
|
|
1738
|
+
|
|
1739
|
+
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
1740
|
+
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
1741
|
+
// callback is defensive-only and never actually fires in practice.
|
|
1742
|
+
/* istanbul ignore next - defensive: list markers never trigger escaping */
|
|
1743
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
1744
|
+
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
1745
|
+
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
1746
|
+
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
1470
1747
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
1471
|
-
|
|
1748
|
+
|
|
1472
1749
|
for (let i = 0; i < lines.length; i++) {
|
|
1473
1750
|
const line = lines[i];
|
|
1474
1751
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
1475
|
-
|
|
1752
|
+
|
|
1476
1753
|
if (match) {
|
|
1477
1754
|
const [, indent, marker, content] = match;
|
|
1478
1755
|
const level = Math.floor(indent.length / 2);
|
|
1479
1756
|
const isOrdered = /^\d+\./.test(marker);
|
|
1480
1757
|
const listType = isOrdered ? 'ol' : 'ul';
|
|
1481
|
-
|
|
1482
|
-
//
|
|
1758
|
+
|
|
1759
|
+
// Task list detection (only in unordered lists)
|
|
1483
1760
|
let listItemContent = content;
|
|
1484
1761
|
let taskListClass = '';
|
|
1485
1762
|
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
1486
1763
|
if (taskMatch && !isOrdered) {
|
|
1487
1764
|
const [, checked, taskContent] = taskMatch;
|
|
1488
1765
|
const isChecked = checked.toLowerCase() === 'x';
|
|
1489
|
-
const checkboxAttr = inline_styles
|
|
1490
|
-
? ' style="margin-right:.5em"'
|
|
1766
|
+
const checkboxAttr = inline_styles
|
|
1767
|
+
? ' style="margin-right:.5em"'
|
|
1491
1768
|
: ` class="${CLASS_PREFIX}task-checkbox"`;
|
|
1492
1769
|
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
|
|
1493
1770
|
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
|
|
1494
1771
|
}
|
|
1495
|
-
|
|
1496
|
-
// Close deeper levels
|
|
1772
|
+
|
|
1773
|
+
// Close deeper nesting levels
|
|
1497
1774
|
while (listStack.length > level + 1) {
|
|
1498
1775
|
const list = listStack.pop();
|
|
1499
1776
|
result.push(`</${list.type}>`);
|
|
1500
1777
|
}
|
|
1501
|
-
|
|
1502
|
-
// Open new
|
|
1778
|
+
|
|
1779
|
+
// Open new list or switch type at current level
|
|
1503
1780
|
if (listStack.length === level) {
|
|
1504
|
-
// Need to open a new list
|
|
1505
1781
|
listStack.push({ type: listType, level });
|
|
1506
1782
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
1507
1783
|
} else if (listStack.length === level + 1) {
|
|
1508
|
-
// Check if we need to switch list type
|
|
1509
1784
|
const currentList = listStack[listStack.length - 1];
|
|
1510
1785
|
if (currentList.type !== listType) {
|
|
1511
1786
|
result.push(`</${currentList.type}>`);
|
|
@@ -1514,11 +1789,11 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
1514
1789
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
1515
1790
|
}
|
|
1516
1791
|
}
|
|
1517
|
-
|
|
1792
|
+
|
|
1518
1793
|
const liAttr = taskListClass || getAttr('li');
|
|
1519
1794
|
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
1520
1795
|
} else {
|
|
1521
|
-
// Not a list item
|
|
1796
|
+
// Not a list item — close all open lists
|
|
1522
1797
|
while (listStack.length > 0) {
|
|
1523
1798
|
const list = listStack.pop();
|
|
1524
1799
|
result.push(`</${list.type}>`);
|
|
@@ -1526,76 +1801,76 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
1526
1801
|
result.push(line);
|
|
1527
1802
|
}
|
|
1528
1803
|
}
|
|
1529
|
-
|
|
1530
|
-
// Close any remaining lists
|
|
1804
|
+
|
|
1805
|
+
// Close any remaining open lists
|
|
1531
1806
|
while (listStack.length > 0) {
|
|
1532
1807
|
const list = listStack.pop();
|
|
1533
1808
|
result.push(`</${list.type}>`);
|
|
1534
1809
|
}
|
|
1535
|
-
|
|
1810
|
+
|
|
1536
1811
|
return result.join('\n');
|
|
1537
1812
|
}
|
|
1538
1813
|
|
|
1814
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1815
|
+
// Static API
|
|
1816
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1817
|
+
|
|
1539
1818
|
/**
|
|
1540
|
-
* Emit CSS
|
|
1541
|
-
*
|
|
1542
|
-
* @param {string}
|
|
1543
|
-
* @
|
|
1819
|
+
* Emit CSS rules for all quikdown elements.
|
|
1820
|
+
*
|
|
1821
|
+
* @param {string} prefix Class prefix (default: 'quikdown-')
|
|
1822
|
+
* @param {string} theme 'light' (default) or 'dark'
|
|
1823
|
+
* @returns {string} CSS text
|
|
1544
1824
|
*/
|
|
1545
1825
|
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
1546
1826
|
const styles = QUIKDOWN_STYLES;
|
|
1547
|
-
|
|
1548
|
-
// Define theme color overrides
|
|
1827
|
+
|
|
1549
1828
|
const themeOverrides = {
|
|
1550
1829
|
dark: {
|
|
1551
|
-
'#f4f4f4': '#2a2a2a',
|
|
1552
|
-
'#f0f0f0': '#2a2a2a',
|
|
1553
|
-
'#f2f2f2': '#2a2a2a',
|
|
1554
|
-
'#ddd': '#3a3a3a',
|
|
1555
|
-
'#06c': '#6db3f2',
|
|
1830
|
+
'#f4f4f4': '#2a2a2a', // pre background
|
|
1831
|
+
'#f0f0f0': '#2a2a2a', // code background
|
|
1832
|
+
'#f2f2f2': '#2a2a2a', // th background
|
|
1833
|
+
'#ddd': '#3a3a3a', // borders
|
|
1834
|
+
'#06c': '#6db3f2', // links
|
|
1556
1835
|
_textColor: '#e0e0e0'
|
|
1557
1836
|
},
|
|
1558
1837
|
light: {
|
|
1559
|
-
_textColor: '#333'
|
|
1838
|
+
_textColor: '#333'
|
|
1560
1839
|
}
|
|
1561
1840
|
};
|
|
1562
|
-
|
|
1841
|
+
|
|
1563
1842
|
let css = '';
|
|
1564
1843
|
for (const [tag, style] of Object.entries(styles)) {
|
|
1565
1844
|
let themedStyle = style;
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
if (!oldColor.startsWith('_')) {
|
|
1572
|
-
themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
// Add text color for certain elements in dark theme
|
|
1577
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1578
|
-
if (needsTextColor.includes(tag)) {
|
|
1579
|
-
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
1580
|
-
}
|
|
1581
|
-
} else if (theme === 'light' && themeOverrides.light) {
|
|
1582
|
-
// Add explicit text color for light theme elements too
|
|
1583
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1584
|
-
if (needsTextColor.includes(tag)) {
|
|
1585
|
-
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
1845
|
+
|
|
1846
|
+
if (theme === 'dark' && themeOverrides.dark) {
|
|
1847
|
+
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
|
|
1848
|
+
if (!oldColor.startsWith('_')) {
|
|
1849
|
+
themedStyle = themedStyle.replaceAll(oldColor, newColor);
|
|
1586
1850
|
}
|
|
1587
1851
|
}
|
|
1588
|
-
|
|
1852
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1853
|
+
if (needsTextColor.includes(tag)) {
|
|
1854
|
+
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
1855
|
+
}
|
|
1856
|
+
} else if (theme === 'light' && themeOverrides.light) {
|
|
1857
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1858
|
+
if (needsTextColor.includes(tag)) {
|
|
1859
|
+
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1589
1863
|
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
1590
1864
|
}
|
|
1591
|
-
|
|
1865
|
+
|
|
1592
1866
|
return css;
|
|
1593
1867
|
};
|
|
1594
1868
|
|
|
1595
1869
|
/**
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
* @
|
|
1870
|
+
* Create a pre-configured parser with baked-in options.
|
|
1871
|
+
*
|
|
1872
|
+
* @param {Object} options Options to bake in
|
|
1873
|
+
* @returns {Function} Configured quikdown(markdown) function
|
|
1599
1874
|
*/
|
|
1600
1875
|
quikdown.configure = function(options) {
|
|
1601
1876
|
return function(markdown) {
|
|
@@ -1603,18 +1878,18 @@ quikdown.configure = function(options) {
|
|
|
1603
1878
|
};
|
|
1604
1879
|
};
|
|
1605
1880
|
|
|
1606
|
-
/**
|
|
1607
|
-
* Version information
|
|
1608
|
-
*/
|
|
1881
|
+
/** Semantic version (injected at build time) */
|
|
1609
1882
|
quikdown.version = quikdownVersion;
|
|
1610
1883
|
|
|
1611
|
-
//
|
|
1884
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1885
|
+
// Exports
|
|
1886
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1887
|
+
|
|
1612
1888
|
/* istanbul ignore next */
|
|
1613
1889
|
if (typeof module !== 'undefined' && module.exports) {
|
|
1614
1890
|
module.exports = quikdown;
|
|
1615
1891
|
}
|
|
1616
1892
|
|
|
1617
|
-
// For browser global
|
|
1618
1893
|
/* istanbul ignore next */
|
|
1619
1894
|
if (typeof window !== 'undefined') {
|
|
1620
1895
|
window.quikdown = quikdown;
|