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