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