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