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