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
|
@@ -594,7 +594,7 @@ var quikchat = /*#__PURE__*/function () {
|
|
|
594
594
|
value: function _autoGrowTextarea() {
|
|
595
595
|
var el = this._textEntry;
|
|
596
596
|
el.style.height = 'auto';
|
|
597
|
-
var maxHeight =
|
|
597
|
+
var maxHeight = 120;
|
|
598
598
|
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
|
599
599
|
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
|
600
600
|
}
|
|
@@ -1062,9 +1062,9 @@ var quikchat = /*#__PURE__*/function () {
|
|
|
1062
1062
|
key: "version",
|
|
1063
1063
|
value: function version() {
|
|
1064
1064
|
return {
|
|
1065
|
-
"version": "1.2.
|
|
1065
|
+
"version": "1.2.7",
|
|
1066
1066
|
"license": "BSD-2",
|
|
1067
|
-
"url": "https://github/deftio/quikchat"
|
|
1067
|
+
"url": "https://github.com/deftio/quikchat"
|
|
1068
1068
|
};
|
|
1069
1069
|
}
|
|
1070
1070
|
|
|
@@ -1122,35 +1122,144 @@ var quikchat = /*#__PURE__*/function () {
|
|
|
1122
1122
|
|
|
1123
1123
|
/**
|
|
1124
1124
|
* quikdown_bd - Bidirectional Markdown Parser
|
|
1125
|
-
* @version 1.
|
|
1125
|
+
* @version 1.2.12
|
|
1126
1126
|
* @license BSD-2-Clause
|
|
1127
1127
|
* @copyright DeftIO 2025
|
|
1128
1128
|
*/
|
|
1129
1129
|
/**
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1130
|
+
* quikdown_classify — Shared line-classification utilities
|
|
1131
|
+
* ═════════════════════════════════════════════════════════
|
|
1132
|
+
*
|
|
1133
|
+
* Pure functions for classifying markdown lines. Used by both the main
|
|
1134
|
+
* parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
|
|
1135
|
+
* lives in one place.
|
|
1136
|
+
*
|
|
1137
|
+
* All functions operate on a **trimmed** line (caller must trim).
|
|
1138
|
+
* None use regexes with nested quantifiers — every check is either a
|
|
1139
|
+
* simple regex or a linear scan, so there is zero ReDoS risk.
|
|
1140
1140
|
*/
|
|
1141
1141
|
|
|
1142
|
-
// Version will be injected at build time
|
|
1143
|
-
const quikdownVersion = '1.1.1';
|
|
1144
1142
|
|
|
1145
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* Dash-only HR check — exact parity with the main parser's original
|
|
1145
|
+
* regex `/^---+\s*$/`. Only matches lines of three or more dashes
|
|
1146
|
+
* with optional trailing whitespace (no interspersed spaces).
|
|
1147
|
+
*
|
|
1148
|
+
* @param {string} trimmed The line, already trimmed
|
|
1149
|
+
* @returns {boolean}
|
|
1150
|
+
*/
|
|
1151
|
+
function isDashHRLine(trimmed) {
|
|
1152
|
+
if (trimmed.length < 3) return false;
|
|
1153
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
1154
|
+
const ch = trimmed[i];
|
|
1155
|
+
if (ch === '-') continue;
|
|
1156
|
+
// Allow trailing whitespace only
|
|
1157
|
+
if (ch === ' ' || ch === '\t') {
|
|
1158
|
+
for (let j = i + 1; j < trimmed.length; j++) {
|
|
1159
|
+
if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
|
|
1160
|
+
}
|
|
1161
|
+
return i >= 3; // at least 3 dashes before whitespace
|
|
1162
|
+
}
|
|
1163
|
+
return false;
|
|
1164
|
+
}
|
|
1165
|
+
return true; // all dashes
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* quikdown — A compact, scanner-based markdown parser
|
|
1170
|
+
* ════════════════════════════════════════════════════
|
|
1171
|
+
*
|
|
1172
|
+
* Architecture overview (v1.2.8 — lexer rewrite)
|
|
1173
|
+
* ───────────────────────────────────────────────
|
|
1174
|
+
* Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
|
|
1175
|
+
* type (headings, blockquotes, HR, lists, tables) and each inline format
|
|
1176
|
+
* (bold, italic, links, …) was handled by its own global regex applied
|
|
1177
|
+
* sequentially to the full document string. That worked but made the code
|
|
1178
|
+
* hard to extend and debug — a new construct meant adding another regex
|
|
1179
|
+
* pass, and ordering bugs between passes were subtle.
|
|
1180
|
+
*
|
|
1181
|
+
* Starting in v1.2.8 the parser uses a **line-scanning** approach for
|
|
1182
|
+
* block detection and a **per-block inline pass** for formatting:
|
|
1183
|
+
*
|
|
1184
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
1185
|
+
* │ Phase 1 — Code Extraction │
|
|
1186
|
+
* │ Scan for fenced code blocks (``` / ~~~) and inline │
|
|
1187
|
+
* │ code spans (`…`). Replace with §CB§ / §IC§ place- │
|
|
1188
|
+
* │ holders so code content is never touched by later │
|
|
1189
|
+
* │ phases. │
|
|
1190
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1191
|
+
* │ Phase 2 — HTML Escaping │
|
|
1192
|
+
* │ Escape &, <, >, ", ' in the remaining text to prevent │
|
|
1193
|
+
* │ XSS. (Skipped when allow_unsafe_html is true.) │
|
|
1194
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1195
|
+
* │ Phase 3 — Block Scanning │
|
|
1196
|
+
* │ Walk the text **line by line**. At each line, the │
|
|
1197
|
+
* │ scanner checks (in order): │
|
|
1198
|
+
* │ • table rows (|) │
|
|
1199
|
+
* │ • headings (#) │
|
|
1200
|
+
* │ • HR (---) │
|
|
1201
|
+
* │ • blockquotes (>) │
|
|
1202
|
+
* │ • list items (-, *, +, 1.) │
|
|
1203
|
+
* │ • code-block placeholder (§CB…§) │
|
|
1204
|
+
* │ • paragraph text (everything else) │
|
|
1205
|
+
* │ │
|
|
1206
|
+
* │ Block text is run through the **inline formatter** │
|
|
1207
|
+
* │ which handles bold, italic, strikethrough, links, │
|
|
1208
|
+
* │ images, and autolinks. │
|
|
1209
|
+
* │ │
|
|
1210
|
+
* │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
|
|
1211
|
+
* │ (single \n → <br>) are handled here too. │
|
|
1212
|
+
* ├─────────────────────────────────────────────────────────┤
|
|
1213
|
+
* │ Phase 4 — Code Restoration │
|
|
1214
|
+
* │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
|
|
1215
|
+
* │ / <code> HTML, applying the fence_plugin if present. │
|
|
1216
|
+
* └─────────────────────────────────────────────────────────┘
|
|
1217
|
+
*
|
|
1218
|
+
* Why this design?
|
|
1219
|
+
* • Single pass over lines for block identification — no re-scanning.
|
|
1220
|
+
* • Each block type is a clearly separated branch, easy to add new ones.
|
|
1221
|
+
* • Inline formatting is confined to block text — can't accidentally
|
|
1222
|
+
* match across block boundaries or inside HTML tags.
|
|
1223
|
+
* • Code extraction still uses a simple regex (it's one pattern, not a
|
|
1224
|
+
* chain) because the §-placeholder approach is proven and simple.
|
|
1225
|
+
*
|
|
1226
|
+
* @param {string} markdown The markdown source text
|
|
1227
|
+
* @param {Object} options Configuration (see below)
|
|
1228
|
+
* @returns {string} Rendered HTML
|
|
1229
|
+
*/
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1233
|
+
// Constants
|
|
1234
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1235
|
+
|
|
1236
|
+
/** Build-time version stamp (injected by tools/updateVersion) */
|
|
1237
|
+
const quikdownVersion = '1.2.12';
|
|
1238
|
+
|
|
1239
|
+
/** CSS class prefix used for all generated elements */
|
|
1146
1240
|
const CLASS_PREFIX = 'quikdown-';
|
|
1147
|
-
const PLACEHOLDER_CB = '§CB';
|
|
1148
|
-
const PLACEHOLDER_IC = '§IC';
|
|
1149
1241
|
|
|
1150
|
-
|
|
1242
|
+
/** Placeholder sigils — chosen to be extremely unlikely in real text */
|
|
1243
|
+
const PLACEHOLDER_CB = '§CB'; // fenced code blocks
|
|
1244
|
+
const PLACEHOLDER_IC = '§IC'; // inline code spans
|
|
1245
|
+
const PLACEHOLDER_HT = '§HT'; // safe HTML tags (limited mode)
|
|
1246
|
+
|
|
1247
|
+
/** Attributes whose values need URL sanitization */
|
|
1248
|
+
const URL_ATTRIBUTES = { href:1, src:1, action:1, formaction:1 };
|
|
1249
|
+
|
|
1250
|
+
/** HTML entity escape map */
|
|
1151
1251
|
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
|
|
1152
1252
|
|
|
1153
|
-
//
|
|
1253
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1254
|
+
// Style definitions
|
|
1255
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Inline styles for every element quikdown can emit.
|
|
1259
|
+
* When `inline_styles: true` these are injected as style="…" attributes.
|
|
1260
|
+
* When `inline_styles: false` (default) we use class="quikdown-<tag>"
|
|
1261
|
+
* and these same values are emitted by `quikdown.emitStyles()`.
|
|
1262
|
+
*/
|
|
1154
1263
|
const QUIKDOWN_STYLES = {
|
|
1155
1264
|
h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
|
|
1156
1265
|
h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
|
|
@@ -1173,30 +1282,41 @@ const QUIKDOWN_STYLES = {
|
|
|
1173
1282
|
ul: 'margin:.5em 0;padding-left:2em',
|
|
1174
1283
|
ol: 'margin:.5em 0;padding-left:2em',
|
|
1175
1284
|
li: 'margin:.25em 0',
|
|
1176
|
-
// Task list specific styles
|
|
1177
1285
|
'task-item': 'list-style:none',
|
|
1178
1286
|
'task-checkbox': 'margin-right:.5em'
|
|
1179
1287
|
};
|
|
1180
1288
|
|
|
1181
|
-
//
|
|
1289
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1290
|
+
// Attribute factory
|
|
1291
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Creates a `getAttr(tag, additionalStyle?)` helper that returns
|
|
1295
|
+
* either a class="…" or style="…" attribute string depending on mode.
|
|
1296
|
+
*
|
|
1297
|
+
* @param {boolean} inline_styles True → emit style="…"; false → class="…"
|
|
1298
|
+
* @param {Object} styles The QUIKDOWN_STYLES map
|
|
1299
|
+
* @returns {Function}
|
|
1300
|
+
*/
|
|
1182
1301
|
function createGetAttr(inline_styles, styles) {
|
|
1183
1302
|
return function(tag, additionalStyle = '') {
|
|
1184
1303
|
if (inline_styles) {
|
|
1185
1304
|
let style = styles[tag];
|
|
1186
1305
|
if (!style && !additionalStyle) return '';
|
|
1187
|
-
|
|
1188
|
-
//
|
|
1306
|
+
|
|
1307
|
+
// When adding alignment that conflicts with the tag's default,
|
|
1308
|
+
// strip the default text-align first.
|
|
1189
1309
|
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
|
|
1190
1310
|
style = style.replace(/text-align:[^;]+;?/, '').trim();
|
|
1311
|
+
/* istanbul ignore next */
|
|
1191
1312
|
if (style && !style.endsWith(';')) style += ';';
|
|
1192
1313
|
}
|
|
1193
|
-
|
|
1314
|
+
|
|
1194
1315
|
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
|
|
1195
1316
|
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
|
|
1196
1317
|
return ` style="${fullStyle}"`;
|
|
1197
1318
|
} else {
|
|
1198
1319
|
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
|
|
1199
|
-
// Apply inline styles for alignment even when using CSS classes
|
|
1200
1320
|
if (additionalStyle) {
|
|
1201
1321
|
return `${classAttr} style="${additionalStyle}"`;
|
|
1202
1322
|
}
|
|
@@ -1205,69 +1325,124 @@ function createGetAttr(inline_styles, styles) {
|
|
|
1205
1325
|
};
|
|
1206
1326
|
}
|
|
1207
1327
|
|
|
1328
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1329
|
+
// Main parser function
|
|
1330
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1331
|
+
|
|
1208
1332
|
function quikdown(markdown, options = {}) {
|
|
1333
|
+
// ── Guard: only process non-empty strings ──
|
|
1209
1334
|
if (!markdown || typeof markdown !== 'string') {
|
|
1210
1335
|
return '';
|
|
1211
1336
|
}
|
|
1212
|
-
|
|
1213
|
-
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
|
|
1214
|
-
const styles = QUIKDOWN_STYLES; // Use module-level styles
|
|
1215
|
-
const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
|
|
1216
1337
|
|
|
1217
|
-
//
|
|
1338
|
+
// ── Unpack options ──
|
|
1339
|
+
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
|
|
1340
|
+
const styles = QUIKDOWN_STYLES;
|
|
1341
|
+
const getAttr = createGetAttr(inline_styles, styles);
|
|
1342
|
+
|
|
1343
|
+
// ── Helpers (closed over options) ──
|
|
1344
|
+
|
|
1345
|
+
/** Escape the five HTML-special characters. */
|
|
1218
1346
|
function escapeHtml(text) {
|
|
1219
1347
|
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
|
|
1220
1348
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Bidirectional marker helper.
|
|
1352
|
+
* When bidirectional mode is on, returns ` data-qd="…"`.
|
|
1353
|
+
* The non-bidirectional branch is a trivial no-op arrow; it is
|
|
1354
|
+
* exercised in the core bundle but never in quikdown_bd.
|
|
1355
|
+
*/
|
|
1356
|
+
/* istanbul ignore next - trivial no-op fallback */
|
|
1223
1357
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
1224
|
-
|
|
1225
|
-
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
|
|
1361
|
+
* Returns '#' for blocked URLs.
|
|
1362
|
+
*/
|
|
1226
1363
|
function sanitizeUrl(url, allowUnsafe = false) {
|
|
1227
1364
|
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
|
|
1228
1365
|
if (!url) return '';
|
|
1229
|
-
|
|
1230
|
-
// If unsafe URLs are explicitly allowed, return as-is
|
|
1231
1366
|
if (allowUnsafe) return url;
|
|
1232
|
-
|
|
1367
|
+
|
|
1233
1368
|
const trimmedUrl = url.trim();
|
|
1234
1369
|
const lowerUrl = trimmedUrl.toLowerCase();
|
|
1235
|
-
|
|
1236
|
-
// Block dangerous protocols
|
|
1237
1370
|
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
|
|
1238
|
-
|
|
1371
|
+
|
|
1239
1372
|
for (const protocol of dangerousProtocols) {
|
|
1240
1373
|
if (lowerUrl.startsWith(protocol)) {
|
|
1241
|
-
// Exception: Allow data:image/* for images
|
|
1242
1374
|
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
|
|
1243
1375
|
return trimmedUrl;
|
|
1244
1376
|
}
|
|
1245
|
-
// Return safe empty link for dangerous protocols
|
|
1246
1377
|
return '#';
|
|
1247
1378
|
}
|
|
1248
1379
|
}
|
|
1249
|
-
|
|
1250
1380
|
return trimmedUrl;
|
|
1251
1381
|
}
|
|
1252
1382
|
|
|
1253
|
-
|
|
1383
|
+
/**
|
|
1384
|
+
* Sanitize attributes on an HTML tag string for limited mode.
|
|
1385
|
+
* Strips on* event handlers (case-insensitive) and runs sanitizeUrl()
|
|
1386
|
+
* on href/src/action/formaction values.
|
|
1387
|
+
*/
|
|
1388
|
+
function sanitizeHtmlTagAttrs(tagStr) {
|
|
1389
|
+
// Self-closing or void tag without attributes — pass through
|
|
1390
|
+
if (!/\s/.test(tagStr.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*/, '').replace(/\/?>$/, ''))) {
|
|
1391
|
+
return tagStr;
|
|
1392
|
+
}
|
|
1393
|
+
// Parse: <tagname ...attrs... > or <tagname ...attrs... />
|
|
1394
|
+
const m = tagStr.match(/^(<\/?[a-zA-Z][a-zA-Z0-9]*)([\s\S]*?)(\/?>)$/);
|
|
1395
|
+
/* istanbul ignore next - defensive: Phase 1.5 regex guarantees valid tag shape */
|
|
1396
|
+
if (!m) return tagStr;
|
|
1397
|
+
|
|
1398
|
+
const [, open, attrStr, close] = m;
|
|
1399
|
+
// Match individual attributes: name="value", name='value', name=value, or bare name
|
|
1400
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers
|
|
1401
|
+
const attrRe = /([a-zA-Z_][\w\-.:]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
1402
|
+
const attrs = [];
|
|
1403
|
+
let am;
|
|
1404
|
+
while ((am = attrRe.exec(attrStr)) !== null) {
|
|
1405
|
+
const name = am[1];
|
|
1406
|
+
const value = am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4];
|
|
1407
|
+
// Strip event handlers (on*)
|
|
1408
|
+
if (/^on/i.test(name)) continue;
|
|
1409
|
+
if (value === undefined) {
|
|
1410
|
+
// Boolean attribute (e.g. disabled, checked)
|
|
1411
|
+
attrs.push(name);
|
|
1412
|
+
} else {
|
|
1413
|
+
let sanitized = value;
|
|
1414
|
+
if (name.toLowerCase() in URL_ATTRIBUTES) {
|
|
1415
|
+
sanitized = sanitizeUrl(value);
|
|
1416
|
+
}
|
|
1417
|
+
attrs.push(`${name}="${sanitized}"`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
return open + (attrs.length ? ' ' + attrs.join(' ') : '') + close;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// ────────────────────────────────────────────────────────────────
|
|
1424
|
+
// Phase 1 — Code Extraction
|
|
1425
|
+
// ────────────────────────────────────────────────────────────────
|
|
1426
|
+
// Why extract code first? Fenced blocks and inline code spans can
|
|
1427
|
+
// contain markdown-like characters (*, _, #, |, etc.) that must NOT
|
|
1428
|
+
// be interpreted as formatting. By pulling them out and replacing
|
|
1429
|
+
// with unique placeholders, the rest of the pipeline never sees them.
|
|
1430
|
+
|
|
1254
1431
|
let html = markdown;
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
// Fence must be at start of line
|
|
1432
|
+
const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
|
|
1433
|
+
const inlineCodes = []; // Array of escaped-HTML strings
|
|
1434
|
+
|
|
1435
|
+
// ── Fenced code blocks ──
|
|
1436
|
+
// Matches paired fences: ``` with ``` and ~~~ with ~~~.
|
|
1437
|
+
// The fence must start at column 0 of a line (^ with /m flag).
|
|
1438
|
+
// Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
|
|
1263
1439
|
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
|
|
1264
1440
|
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
|
|
1265
|
-
|
|
1266
|
-
// Trim the language specification
|
|
1267
1441
|
const langTrimmed = lang ? lang.trim() : '';
|
|
1268
|
-
|
|
1269
|
-
// If custom fence plugin is provided, use it (v1.1.0: object format required)
|
|
1442
|
+
|
|
1270
1443
|
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
|
|
1444
|
+
// Custom plugin — store raw code (un-escaped) so the plugin
|
|
1445
|
+
// receives the original source.
|
|
1271
1446
|
codeBlocks.push({
|
|
1272
1447
|
lang: langTrimmed,
|
|
1273
1448
|
code: code.trimEnd(),
|
|
@@ -1276,6 +1451,7 @@ function quikdown(markdown, options = {}) {
|
|
|
1276
1451
|
hasReverse: !!fence_plugin.reverse
|
|
1277
1452
|
});
|
|
1278
1453
|
} else {
|
|
1454
|
+
// Default — pre-escape the code for safe HTML output.
|
|
1279
1455
|
codeBlocks.push({
|
|
1280
1456
|
lang: langTrimmed,
|
|
1281
1457
|
code: escapeHtml(code.trimEnd()),
|
|
@@ -1285,66 +1461,137 @@ function quikdown(markdown, options = {}) {
|
|
|
1285
1461
|
}
|
|
1286
1462
|
return placeholder;
|
|
1287
1463
|
});
|
|
1288
|
-
|
|
1289
|
-
//
|
|
1464
|
+
|
|
1465
|
+
// ── Inline code spans ──
|
|
1466
|
+
// Matches a single backtick pair: `content`.
|
|
1467
|
+
// Content is captured and HTML-escaped immediately.
|
|
1290
1468
|
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
|
1291
1469
|
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
|
|
1292
1470
|
inlineCodes.push(escapeHtml(code));
|
|
1293
1471
|
return placeholder;
|
|
1294
1472
|
});
|
|
1295
|
-
|
|
1296
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
//
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1473
|
+
|
|
1474
|
+
// ────────────────────────────────────────────────────────────────
|
|
1475
|
+
// Phase 1.5 — Safe HTML Extraction (whitelist mode)
|
|
1476
|
+
// ────────────────────────────────────────────────────────────────
|
|
1477
|
+
// When allow_unsafe_html is an object or array, extract whitelisted
|
|
1478
|
+
// HTML tags, sanitize their attributes, and replace with placeholders.
|
|
1479
|
+
// Non-whitelisted tags stay in text so Phase 2 will escape them.
|
|
1480
|
+
|
|
1481
|
+
const safeTags = [];
|
|
1482
|
+
// Normalize: array → object for O(1) lookup; object used as-is
|
|
1483
|
+
const htmlAllow = Array.isArray(allow_unsafe_html)
|
|
1484
|
+
? Object.fromEntries(allow_unsafe_html.map(t => [t, 1]))
|
|
1485
|
+
: (allow_unsafe_html && typeof allow_unsafe_html === 'object') ? allow_unsafe_html : null;
|
|
1486
|
+
|
|
1487
|
+
if (htmlAllow) {
|
|
1488
|
+
// Pass through HTML comments — browsers render them as nothing
|
|
1489
|
+
html = html.replace(/<!--[\s\S]*?-->/g, (match) => {
|
|
1490
|
+
const idx = safeTags.length;
|
|
1491
|
+
safeTags.push(match);
|
|
1492
|
+
return `${PLACEHOLDER_HT}${idx}§`;
|
|
1493
|
+
});
|
|
1494
|
+
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tagName) => {
|
|
1495
|
+
if (tagName.toLowerCase() in htmlAllow) {
|
|
1496
|
+
const sanitized = sanitizeHtmlTagAttrs(match);
|
|
1497
|
+
const idx = safeTags.length;
|
|
1498
|
+
safeTags.push(sanitized);
|
|
1499
|
+
return `${PLACEHOLDER_HT}${idx}§`;
|
|
1500
|
+
}
|
|
1501
|
+
// Not whitelisted — leave in text for Phase 2 to escape
|
|
1502
|
+
return match;
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// ────────────────────────────────────────────────────────────────
|
|
1507
|
+
// Phase 2 — HTML Escaping
|
|
1508
|
+
// ────────────────────────────────────────────────────────────────
|
|
1509
|
+
// All remaining text (everything except code placeholders) is escaped
|
|
1510
|
+
// to prevent XSS. The `allow_unsafe_html` option skips this for
|
|
1511
|
+
// trusted pipelines that intentionally embed raw HTML.
|
|
1512
|
+
// For whitelist mode, escaping still runs (only `true` bypasses it).
|
|
1513
|
+
|
|
1514
|
+
if (allow_unsafe_html !== true) {
|
|
1515
|
+
html = escapeHtml(html);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Restore safe HTML tag placeholders after escaping
|
|
1519
|
+
if (htmlAllow) {
|
|
1520
|
+
safeTags.forEach((tag, i) => {
|
|
1521
|
+
html = html.replace(`${PLACEHOLDER_HT}${i}§`, tag);
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ────────────────────────────────────────────────────────────────
|
|
1526
|
+
// Phase 3 — Block Scanning + Inline Formatting + Paragraphs
|
|
1527
|
+
// ────────────────────────────────────────────────────────────────
|
|
1528
|
+
// This is the heart of the lexer rewrite. Instead of applying
|
|
1529
|
+
// 10+ global regex passes, we:
|
|
1530
|
+
// 1. Process tables (line walker — tables need multi-line lookahead)
|
|
1531
|
+
// 2. Scan remaining lines for headings, HR, blockquotes
|
|
1532
|
+
// 3. Process lists (line walker — lists need indent tracking)
|
|
1533
|
+
// 4. Apply inline formatting to all text content
|
|
1534
|
+
// 5. Wrap remaining text in <p> tags
|
|
1535
|
+
//
|
|
1536
|
+
// Steps 1 and 3 are line-walkers that process the full text in a
|
|
1537
|
+
// single pass each. Step 2 replaces global regex with a per-line
|
|
1538
|
+
// scanner. Steps 4-5 are applied to the result.
|
|
1539
|
+
//
|
|
1540
|
+
// Total: 3 structured passes instead of 10+ regex passes.
|
|
1541
|
+
|
|
1542
|
+
// ── Step 1: Tables ──
|
|
1543
|
+
// Tables need multi-line lookahead (header → separator → body rows)
|
|
1544
|
+
// so they're handled by a dedicated line-walker first.
|
|
1302
1545
|
html = processTable(html, getAttr);
|
|
1303
|
-
|
|
1304
|
-
//
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
//
|
|
1311
|
-
|
|
1312
|
-
// Merge consecutive blockquotes
|
|
1313
|
-
html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
1314
|
-
|
|
1315
|
-
// Process horizontal rules (allow trailing spaces)
|
|
1316
|
-
html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
|
|
1317
|
-
|
|
1318
|
-
// Process lists
|
|
1546
|
+
|
|
1547
|
+
// ── Step 2: Headings, HR, Blockquotes ──
|
|
1548
|
+
// These are simple line-level constructs. We scan each line once
|
|
1549
|
+
// and replace matching lines with their HTML representation.
|
|
1550
|
+
html = scanLineBlocks(html, getAttr, dataQd);
|
|
1551
|
+
|
|
1552
|
+
// ── Step 3: Lists ──
|
|
1553
|
+
// Lists need indent-level tracking across lines, so they get their
|
|
1554
|
+
// own line-walker.
|
|
1319
1555
|
html = processLists(html, getAttr, inline_styles, bidirectional);
|
|
1320
|
-
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
//
|
|
1556
|
+
|
|
1557
|
+
// ── Step 4: Inline formatting ──
|
|
1558
|
+
// Apply bold, italic, strikethrough, images, links, and autolinks
|
|
1559
|
+
// to all text content. This runs on the output of steps 1-3, so
|
|
1560
|
+
// it sees text inside headings, blockquotes, table cells, list
|
|
1561
|
+
// items, and paragraph text.
|
|
1562
|
+
|
|
1563
|
+
// Images (must come before links —  vs [text](url))
|
|
1324
1564
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
|
1325
1565
|
const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
|
|
1566
|
+
/* istanbul ignore next - bd-only branch */
|
|
1326
1567
|
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
|
|
1568
|
+
/* istanbul ignore next - bd-only branch */
|
|
1327
1569
|
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
|
|
1328
1570
|
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
|
|
1329
1571
|
});
|
|
1330
|
-
|
|
1331
|
-
// Links
|
|
1572
|
+
|
|
1573
|
+
// Links
|
|
1332
1574
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
|
|
1333
|
-
// Sanitize URL to prevent XSS
|
|
1334
1575
|
const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
|
|
1335
1576
|
const isExternal = /^https?:\/\//i.test(sanitizedHref);
|
|
1336
1577
|
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
|
|
1578
|
+
/* istanbul ignore next - bd-only branch */
|
|
1337
1579
|
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
|
|
1338
1580
|
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
|
|
1339
1581
|
});
|
|
1340
|
-
|
|
1341
|
-
// Autolinks
|
|
1582
|
+
|
|
1583
|
+
// Autolinks — bare https?:// URLs become clickable <a> tags
|
|
1342
1584
|
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
|
|
1343
1585
|
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
|
|
1344
1586
|
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
|
|
1345
1587
|
});
|
|
1346
|
-
|
|
1347
|
-
//
|
|
1588
|
+
|
|
1589
|
+
// Protect rendered tags so emphasis regexes don't see attribute
|
|
1590
|
+
// values — fixes #3 (underscores in URLs interpreted as emphasis).
|
|
1591
|
+
const savedTags = [];
|
|
1592
|
+
html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });
|
|
1593
|
+
|
|
1594
|
+
// Bold, italic, strikethrough
|
|
1348
1595
|
const inlinePatterns = [
|
|
1349
1596
|
[/\*\*(.+?)\*\*/g, 'strong', '**'],
|
|
1350
1597
|
[/__(.+?)__/g, 'strong', '__'],
|
|
@@ -1352,60 +1599,66 @@ function quikdown(markdown, options = {}) {
|
|
|
1352
1599
|
[/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
|
|
1353
1600
|
[/~~(.+?)~~/g, 'del', '~~']
|
|
1354
1601
|
];
|
|
1355
|
-
|
|
1356
1602
|
inlinePatterns.forEach(([pattern, tag, marker]) => {
|
|
1357
1603
|
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
|
|
1358
1604
|
});
|
|
1359
|
-
|
|
1360
|
-
//
|
|
1605
|
+
|
|
1606
|
+
// Restore protected tags
|
|
1607
|
+
html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);
|
|
1608
|
+
|
|
1609
|
+
// ── Step 5: Line breaks + paragraph wrapping ──
|
|
1361
1610
|
if (lazy_linefeeds) {
|
|
1362
|
-
// Lazy linefeeds: single
|
|
1611
|
+
// Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
|
|
1612
|
+
// • Double newlines → paragraph break
|
|
1613
|
+
// • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
|
|
1614
|
+
//
|
|
1615
|
+
// Strategy: protect block-adjacent newlines with §N§, convert
|
|
1616
|
+
// the rest, then restore.
|
|
1617
|
+
|
|
1363
1618
|
const blocks = [];
|
|
1364
1619
|
let bi = 0;
|
|
1365
|
-
|
|
1366
|
-
// Protect tables and lists
|
|
1620
|
+
|
|
1621
|
+
// Protect tables and lists from <br> injection
|
|
1367
1622
|
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
|
|
1368
1623
|
blocks[bi] = m;
|
|
1369
1624
|
return `§B${bi++}§`;
|
|
1370
1625
|
});
|
|
1371
|
-
|
|
1372
|
-
// Handle paragraphs and block elements
|
|
1626
|
+
|
|
1373
1627
|
html = html.replace(/\n\n+/g, '§P§')
|
|
1374
|
-
// After block
|
|
1628
|
+
// After block-level closing tags
|
|
1375
1629
|
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
|
|
1376
1630
|
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
|
|
1377
|
-
// Before block
|
|
1631
|
+
// Before block-level opening tags
|
|
1378
1632
|
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
|
|
1379
1633
|
.replace(/\n(§B\d+§)/g, '§N§$1')
|
|
1380
1634
|
.replace(/(§B\d+§)\n/g, '$1§N§')
|
|
1381
|
-
// Convert
|
|
1635
|
+
// Convert surviving newlines to <br>
|
|
1382
1636
|
.replace(/\n/g, `<br${getAttr('br')}>`)
|
|
1383
1637
|
// Restore
|
|
1384
1638
|
.replace(/§N§/g, '\n')
|
|
1385
1639
|
.replace(/§P§/g, '</p><p>');
|
|
1386
|
-
|
|
1640
|
+
|
|
1387
1641
|
// Restore protected blocks
|
|
1388
1642
|
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
|
|
1389
|
-
|
|
1643
|
+
|
|
1390
1644
|
html = '<p>' + html + '</p>';
|
|
1391
1645
|
} else {
|
|
1392
|
-
// Standard: two spaces
|
|
1393
|
-
html = html.replace(/
|
|
1394
|
-
|
|
1395
|
-
// Paragraphs (double newlines)
|
|
1396
|
-
// Don't add </p> after block elements (they're not in paragraphs)
|
|
1646
|
+
// Standard mode: two trailing spaces → <br>, double newline → new paragraph
|
|
1647
|
+
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
|
|
1648
|
+
|
|
1397
1649
|
html = html.replace(/\n\n+/g, (match, offset) => {
|
|
1398
|
-
// Check if we're after a block element closing tag
|
|
1399
1650
|
const before = html.substring(0, offset);
|
|
1400
1651
|
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
|
|
1401
|
-
return '<p>';
|
|
1652
|
+
return '<p>';
|
|
1402
1653
|
}
|
|
1403
|
-
return '</p><p>';
|
|
1654
|
+
return '</p><p>';
|
|
1404
1655
|
});
|
|
1405
1656
|
html = '<p>' + html + '</p>';
|
|
1406
1657
|
}
|
|
1407
|
-
|
|
1408
|
-
//
|
|
1658
|
+
|
|
1659
|
+
// ── Step 6: Cleanup ──
|
|
1660
|
+
// Remove <p> wrappers that accidentally enclose block elements.
|
|
1661
|
+
// This is simpler than trying to prevent them during wrapping.
|
|
1409
1662
|
const cleanupPatterns = [
|
|
1410
1663
|
[/<p><\/p>/g, ''],
|
|
1411
1664
|
[/<p>(<h[1-6][^>]*>)/g, '$1'],
|
|
@@ -1419,67 +1672,162 @@ function quikdown(markdown, options = {}) {
|
|
|
1419
1672
|
[/(<\/table>)<\/p>/g, '$1'],
|
|
1420
1673
|
[/<p>(<pre[^>]*>)/g, '$1'],
|
|
1421
1674
|
[/(<\/pre>)<\/p>/g, '$1'],
|
|
1422
|
-
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)
|
|
1675
|
+
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
|
|
1423
1676
|
];
|
|
1424
|
-
|
|
1425
1677
|
cleanupPatterns.forEach(([pattern, replacement]) => {
|
|
1426
1678
|
html = html.replace(pattern, replacement);
|
|
1427
1679
|
});
|
|
1428
|
-
|
|
1429
|
-
//
|
|
1430
|
-
// When a paragraph follows a block element, ensure it has opening <p>
|
|
1680
|
+
|
|
1681
|
+
// When a block element is followed by a newline and then text, open a <p>.
|
|
1431
1682
|
html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
|
|
1432
|
-
|
|
1433
|
-
//
|
|
1434
|
-
|
|
1435
|
-
//
|
|
1683
|
+
|
|
1684
|
+
// ────────────────────────────────────────────────────────────────
|
|
1685
|
+
// Phase 4 — Code Restoration
|
|
1686
|
+
// ────────────────────────────────────────────────────────────────
|
|
1687
|
+
// Replace placeholders with rendered HTML. For fenced blocks this
|
|
1688
|
+
// means wrapping in <pre><code>…</code></pre> (or calling the
|
|
1689
|
+
// fence_plugin). For inline code it means <code>…</code>.
|
|
1690
|
+
|
|
1436
1691
|
codeBlocks.forEach((block, i) => {
|
|
1437
1692
|
let replacement;
|
|
1438
|
-
|
|
1693
|
+
|
|
1439
1694
|
if (block.custom && fence_plugin && fence_plugin.render) {
|
|
1440
|
-
//
|
|
1695
|
+
// Delegate to the user-provided fence plugin.
|
|
1441
1696
|
replacement = fence_plugin.render(block.code, block.lang);
|
|
1442
|
-
|
|
1443
|
-
// If plugin returns undefined, fall back to default rendering
|
|
1697
|
+
|
|
1444
1698
|
if (replacement === undefined) {
|
|
1699
|
+
// Plugin declined — fall back to default rendering.
|
|
1445
1700
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
1446
1701
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
1702
|
+
/* istanbul ignore next - bd-only branch */
|
|
1447
1703
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
1704
|
+
/* istanbul ignore next - bd-only branch */
|
|
1448
1705
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
1449
1706
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
|
|
1450
|
-
} else if (bidirectional) {
|
|
1451
|
-
//
|
|
1452
|
-
replacement = replacement.replace(/^<(\w+)/,
|
|
1707
|
+
} else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
|
|
1708
|
+
// Plugin returned HTML — inject data attributes for roundtrip.
|
|
1709
|
+
replacement = replacement.replace(/^<(\w+)/,
|
|
1453
1710
|
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
|
|
1454
1711
|
}
|
|
1455
1712
|
} else {
|
|
1456
|
-
// Default rendering
|
|
1713
|
+
// Default rendering — wrap in <pre><code>.
|
|
1457
1714
|
const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
|
|
1458
1715
|
const codeAttr = inline_styles ? getAttr('code') : langClass;
|
|
1716
|
+
/* istanbul ignore next - bd-only branch */
|
|
1459
1717
|
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
|
|
1718
|
+
/* istanbul ignore next - bd-only branch */
|
|
1460
1719
|
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
|
|
1461
1720
|
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
|
|
1462
1721
|
}
|
|
1463
|
-
|
|
1722
|
+
|
|
1464
1723
|
const placeholder = `${PLACEHOLDER_CB}${i}§`;
|
|
1465
1724
|
html = html.replace(placeholder, replacement);
|
|
1466
1725
|
});
|
|
1467
|
-
|
|
1468
|
-
// Restore inline code
|
|
1726
|
+
|
|
1727
|
+
// Restore inline code spans
|
|
1469
1728
|
inlineCodes.forEach((code, i) => {
|
|
1470
1729
|
const placeholder = `${PLACEHOLDER_IC}${i}§`;
|
|
1471
1730
|
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
|
|
1472
1731
|
});
|
|
1473
|
-
|
|
1732
|
+
|
|
1474
1733
|
return html.trim();
|
|
1475
1734
|
}
|
|
1476
1735
|
|
|
1736
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1737
|
+
// Block-level line scanner
|
|
1738
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
|
|
1742
|
+
*
|
|
1743
|
+
* Walks the text line by line. For each line it checks (in order):
|
|
1744
|
+
* 1. Heading — starts with 1-6 '#' followed by a space
|
|
1745
|
+
* 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
|
|
1746
|
+
* 3. Blockquote — starts with '> ' (the > was already HTML-escaped)
|
|
1747
|
+
*
|
|
1748
|
+
* Lines that don't match any block pattern are passed through unchanged.
|
|
1749
|
+
*
|
|
1750
|
+
* This replaces three separate global regex passes from the pre-1.2.8
|
|
1751
|
+
* architecture with one structured scan.
|
|
1752
|
+
*
|
|
1753
|
+
* @param {string} text The document text (HTML-escaped, code extracted)
|
|
1754
|
+
* @param {Function} getAttr Attribute factory (class or style)
|
|
1755
|
+
* @param {Function} dataQd Bidirectional marker factory
|
|
1756
|
+
* @returns {string} Text with block-level elements rendered
|
|
1757
|
+
*/
|
|
1758
|
+
function scanLineBlocks(text, getAttr, dataQd) {
|
|
1759
|
+
const lines = text.split('\n');
|
|
1760
|
+
const result = [];
|
|
1761
|
+
let i = 0;
|
|
1762
|
+
|
|
1763
|
+
while (i < lines.length) {
|
|
1764
|
+
const line = lines[i];
|
|
1765
|
+
|
|
1766
|
+
// ── Markdown comment (reference-link hack) ──
|
|
1767
|
+
// [//]: # (comment) or [//]: # "comment" or [//]: #
|
|
1768
|
+
// These produce no output — standard markdown comment convention.
|
|
1769
|
+
if (/^\[\/\/\]: #/.test(line)) {
|
|
1770
|
+
i++;
|
|
1771
|
+
continue;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ── Heading ──
|
|
1775
|
+
// Count leading '#' characters. Valid heading: 1-6 hashes then a space.
|
|
1776
|
+
// Example: "## Hello World ##" → <h2>Hello World</h2>
|
|
1777
|
+
let hashCount = 0;
|
|
1778
|
+
while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
|
|
1779
|
+
hashCount++;
|
|
1780
|
+
}
|
|
1781
|
+
if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
|
|
1782
|
+
// Extract content after "# " and strip trailing hashes
|
|
1783
|
+
const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
|
|
1784
|
+
const tag = 'h' + hashCount;
|
|
1785
|
+
result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
|
|
1786
|
+
i++;
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// ── Horizontal Rule ──
|
|
1791
|
+
// Three or more dashes, optional trailing whitespace, nothing else.
|
|
1792
|
+
if (isDashHRLine(line)) {
|
|
1793
|
+
result.push(`<hr${getAttr('hr')}>`);
|
|
1794
|
+
i++;
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// ── Blockquote ──
|
|
1799
|
+
// After Phase 2, the '>' character has been escaped to '>'.
|
|
1800
|
+
// Pattern: "> content" or merged consecutive blockquotes.
|
|
1801
|
+
if (/^>\s+/.test(line)) {
|
|
1802
|
+
result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^>\s+/, '')}</blockquote>`);
|
|
1803
|
+
i++;
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// ── Pass-through ──
|
|
1808
|
+
result.push(line);
|
|
1809
|
+
i++;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// Merge consecutive blockquotes into a single element.
|
|
1813
|
+
// <blockquote>A</blockquote>\n<blockquote>B</blockquote>
|
|
1814
|
+
// → <blockquote>A\nB</blockquote>
|
|
1815
|
+
let joined = result.join('\n');
|
|
1816
|
+
joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
|
|
1817
|
+
return joined;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1821
|
+
// Table processing (line walker)
|
|
1822
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1823
|
+
|
|
1477
1824
|
/**
|
|
1478
|
-
*
|
|
1825
|
+
* Inline markdown formatter for table cells.
|
|
1826
|
+
* Handles bold, italic, strikethrough, and code within cell text.
|
|
1827
|
+
* Links / images / autolinks are handled by the global inline pass
|
|
1828
|
+
* (Phase 3 Step 4) which runs after table processing.
|
|
1479
1829
|
*/
|
|
1480
1830
|
function processInlineMarkdown(text, getAttr) {
|
|
1481
|
-
|
|
1482
|
-
// Process inline formatting patterns
|
|
1483
1831
|
const patterns = [
|
|
1484
1832
|
[/\*\*(.+?)\*\*/g, 'strong'],
|
|
1485
1833
|
[/__(.+?)__/g, 'strong'],
|
|
@@ -1488,27 +1836,32 @@ function processInlineMarkdown(text, getAttr) {
|
|
|
1488
1836
|
[/~~(.+?)~~/g, 'del'],
|
|
1489
1837
|
[/`([^`]+)`/g, 'code']
|
|
1490
1838
|
];
|
|
1491
|
-
|
|
1492
1839
|
patterns.forEach(([pattern, tag]) => {
|
|
1493
1840
|
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
|
|
1494
1841
|
});
|
|
1495
|
-
|
|
1496
1842
|
return text;
|
|
1497
1843
|
}
|
|
1498
1844
|
|
|
1499
1845
|
/**
|
|
1500
|
-
*
|
|
1846
|
+
* processTable — line walker for markdown tables
|
|
1847
|
+
*
|
|
1848
|
+
* Walks through lines looking for runs of pipe-containing lines.
|
|
1849
|
+
* Each run is validated (must contain a separator row: |---|---|)
|
|
1850
|
+
* and rendered as an HTML <table>. Invalid runs are restored as-is.
|
|
1851
|
+
*
|
|
1852
|
+
* @param {string} text Full document text
|
|
1853
|
+
* @param {Function} getAttr Attribute factory
|
|
1854
|
+
* @returns {string} Text with tables rendered
|
|
1501
1855
|
*/
|
|
1502
1856
|
function processTable(text, getAttr) {
|
|
1503
1857
|
const lines = text.split('\n');
|
|
1504
1858
|
const result = [];
|
|
1505
1859
|
let inTable = false;
|
|
1506
1860
|
let tableLines = [];
|
|
1507
|
-
|
|
1861
|
+
|
|
1508
1862
|
for (let i = 0; i < lines.length; i++) {
|
|
1509
1863
|
const line = lines[i].trim();
|
|
1510
|
-
|
|
1511
|
-
// Check if this line looks like a table row (with or without trailing |)
|
|
1864
|
+
|
|
1512
1865
|
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
|
|
1513
1866
|
if (!inTable) {
|
|
1514
1867
|
inTable = true;
|
|
@@ -1516,14 +1869,11 @@ function processTable(text, getAttr) {
|
|
|
1516
1869
|
}
|
|
1517
1870
|
tableLines.push(line);
|
|
1518
1871
|
} else {
|
|
1519
|
-
// Not a table line
|
|
1520
1872
|
if (inTable) {
|
|
1521
|
-
// Process the accumulated table
|
|
1522
1873
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
1523
1874
|
if (tableHtml) {
|
|
1524
1875
|
result.push(tableHtml);
|
|
1525
1876
|
} else {
|
|
1526
|
-
// Not a valid table, restore original lines
|
|
1527
1877
|
result.push(...tableLines);
|
|
1528
1878
|
}
|
|
1529
1879
|
inTable = false;
|
|
@@ -1532,8 +1882,8 @@ function processTable(text, getAttr) {
|
|
|
1532
1882
|
result.push(lines[i]);
|
|
1533
1883
|
}
|
|
1534
1884
|
}
|
|
1535
|
-
|
|
1536
|
-
// Handle table at end of
|
|
1885
|
+
|
|
1886
|
+
// Handle table at end of document
|
|
1537
1887
|
if (inTable && tableLines.length > 0) {
|
|
1538
1888
|
const tableHtml = buildTable(tableLines, getAttr);
|
|
1539
1889
|
if (tableHtml) {
|
|
@@ -1542,35 +1892,35 @@ function processTable(text, getAttr) {
|
|
|
1542
1892
|
result.push(...tableLines);
|
|
1543
1893
|
}
|
|
1544
1894
|
}
|
|
1545
|
-
|
|
1895
|
+
|
|
1546
1896
|
return result.join('\n');
|
|
1547
1897
|
}
|
|
1548
1898
|
|
|
1549
1899
|
/**
|
|
1550
|
-
*
|
|
1900
|
+
* buildTable — validate and render a table from accumulated lines
|
|
1901
|
+
*
|
|
1902
|
+
* @param {string[]} lines Array of pipe-containing lines
|
|
1903
|
+
* @param {Function} getAttr Attribute factory
|
|
1904
|
+
* @returns {string|null} HTML table string, or null if invalid
|
|
1551
1905
|
*/
|
|
1552
1906
|
function buildTable(lines, getAttr) {
|
|
1553
|
-
|
|
1554
1907
|
if (lines.length < 2) return null;
|
|
1555
|
-
|
|
1556
|
-
//
|
|
1908
|
+
|
|
1909
|
+
// Find the separator row (---|---|)
|
|
1557
1910
|
let separatorIndex = -1;
|
|
1558
1911
|
for (let i = 1; i < lines.length; i++) {
|
|
1559
|
-
// Support separator with or without leading/trailing pipes
|
|
1560
1912
|
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
|
|
1561
1913
|
separatorIndex = i;
|
|
1562
1914
|
break;
|
|
1563
1915
|
}
|
|
1564
1916
|
}
|
|
1565
|
-
|
|
1566
1917
|
if (separatorIndex === -1) return null;
|
|
1567
|
-
|
|
1918
|
+
|
|
1568
1919
|
const headerLines = lines.slice(0, separatorIndex);
|
|
1569
1920
|
const bodyLines = lines.slice(separatorIndex + 1);
|
|
1570
|
-
|
|
1571
|
-
// Parse alignment from separator
|
|
1921
|
+
|
|
1922
|
+
// Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
|
|
1572
1923
|
const separator = lines[separatorIndex];
|
|
1573
|
-
// Handle pipes at start/end or not
|
|
1574
1924
|
const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1575
1925
|
const alignments = separatorCells.map(cell => {
|
|
1576
1926
|
const trimmed = cell.trim();
|
|
@@ -1578,31 +1928,28 @@ function buildTable(lines, getAttr) {
|
|
|
1578
1928
|
if (trimmed.endsWith(':')) return 'right';
|
|
1579
1929
|
return 'left';
|
|
1580
1930
|
});
|
|
1581
|
-
|
|
1931
|
+
|
|
1582
1932
|
let html = `<table${getAttr('table')}>\n`;
|
|
1583
|
-
|
|
1584
|
-
//
|
|
1585
|
-
// Note: headerLines will always have length > 0 since separatorIndex starts from 1
|
|
1933
|
+
|
|
1934
|
+
// Header
|
|
1586
1935
|
html += `<thead${getAttr('thead')}>\n`;
|
|
1587
1936
|
headerLines.forEach(line => {
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
html += '</tr>\n';
|
|
1937
|
+
html += `<tr${getAttr('tr')}>\n`;
|
|
1938
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1939
|
+
cells.forEach((cell, i) => {
|
|
1940
|
+
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
1941
|
+
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
|
|
1942
|
+
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
|
|
1943
|
+
});
|
|
1944
|
+
html += '</tr>\n';
|
|
1597
1945
|
});
|
|
1598
1946
|
html += '</thead>\n';
|
|
1599
|
-
|
|
1600
|
-
//
|
|
1947
|
+
|
|
1948
|
+
// Body
|
|
1601
1949
|
if (bodyLines.length > 0) {
|
|
1602
1950
|
html += `<tbody${getAttr('tbody')}>\n`;
|
|
1603
1951
|
bodyLines.forEach(line => {
|
|
1604
1952
|
html += `<tr${getAttr('tr')}>\n`;
|
|
1605
|
-
// Handle pipes at start/end or not
|
|
1606
1953
|
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
|
1607
1954
|
cells.forEach((cell, i) => {
|
|
1608
1955
|
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
|
|
@@ -1613,61 +1960,81 @@ function buildTable(lines, getAttr) {
|
|
|
1613
1960
|
});
|
|
1614
1961
|
html += '</tbody>\n';
|
|
1615
1962
|
}
|
|
1616
|
-
|
|
1963
|
+
|
|
1617
1964
|
html += '</table>';
|
|
1618
1965
|
return html;
|
|
1619
1966
|
}
|
|
1620
1967
|
|
|
1968
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1969
|
+
// List processing (line walker)
|
|
1970
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1971
|
+
|
|
1621
1972
|
/**
|
|
1622
|
-
*
|
|
1973
|
+
* processLists — line walker for ordered, unordered, and task lists
|
|
1974
|
+
*
|
|
1975
|
+
* Scans each line for list markers (-, *, +, 1., 2., etc.) with
|
|
1976
|
+
* optional leading indentation for nesting. Non-list lines close
|
|
1977
|
+
* any open lists and pass through unchanged.
|
|
1978
|
+
*
|
|
1979
|
+
* Task lists (- [ ] / - [x]) are detected and rendered with
|
|
1980
|
+
* checkbox inputs.
|
|
1981
|
+
*
|
|
1982
|
+
* @param {string} text Full document text
|
|
1983
|
+
* @param {Function} getAttr Attribute factory
|
|
1984
|
+
* @param {boolean} inline_styles Whether to use inline styles
|
|
1985
|
+
* @param {boolean} bidirectional Whether to add data-qd markers
|
|
1986
|
+
* @returns {string} Text with lists rendered
|
|
1623
1987
|
*/
|
|
1624
1988
|
function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
1625
|
-
|
|
1626
1989
|
const lines = text.split('\n');
|
|
1627
1990
|
const result = [];
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
// Helper to escape HTML for data-qd attributes
|
|
1631
|
-
|
|
1991
|
+
const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
|
|
1992
|
+
|
|
1993
|
+
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
|
|
1994
|
+
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
|
|
1995
|
+
// callback is defensive-only and never actually fires in practice.
|
|
1996
|
+
/* istanbul ignore next - defensive: list markers never trigger escaping */
|
|
1997
|
+
const escapeHtml = (text) => text.replace(/[&<>"']/g,
|
|
1998
|
+
/* istanbul ignore next - defensive: list markers never contain HTML specials */
|
|
1999
|
+
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
|
|
2000
|
+
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
|
|
1632
2001
|
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
|
|
1633
|
-
|
|
2002
|
+
|
|
1634
2003
|
for (let i = 0; i < lines.length; i++) {
|
|
1635
2004
|
const line = lines[i];
|
|
1636
2005
|
const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
|
|
1637
|
-
|
|
2006
|
+
|
|
1638
2007
|
if (match) {
|
|
1639
2008
|
const [, indent, marker, content] = match;
|
|
1640
2009
|
const level = Math.floor(indent.length / 2);
|
|
1641
2010
|
const isOrdered = /^\d+\./.test(marker);
|
|
1642
2011
|
const listType = isOrdered ? 'ol' : 'ul';
|
|
1643
|
-
|
|
1644
|
-
//
|
|
2012
|
+
|
|
2013
|
+
// Task list detection (only in unordered lists)
|
|
1645
2014
|
let listItemContent = content;
|
|
1646
2015
|
let taskListClass = '';
|
|
1647
2016
|
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
|
|
1648
2017
|
if (taskMatch && !isOrdered) {
|
|
1649
2018
|
const [, checked, taskContent] = taskMatch;
|
|
1650
2019
|
const isChecked = checked.toLowerCase() === 'x';
|
|
1651
|
-
const checkboxAttr = inline_styles
|
|
1652
|
-
? ' style="margin-right:.5em"'
|
|
2020
|
+
const checkboxAttr = inline_styles
|
|
2021
|
+
? ' style="margin-right:.5em"'
|
|
1653
2022
|
: ` class="${CLASS_PREFIX}task-checkbox"`;
|
|
1654
2023
|
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
|
|
1655
2024
|
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
|
|
1656
2025
|
}
|
|
1657
|
-
|
|
1658
|
-
// Close deeper levels
|
|
2026
|
+
|
|
2027
|
+
// Close deeper nesting levels
|
|
1659
2028
|
while (listStack.length > level + 1) {
|
|
1660
2029
|
const list = listStack.pop();
|
|
1661
2030
|
result.push(`</${list.type}>`);
|
|
1662
2031
|
}
|
|
1663
|
-
|
|
1664
|
-
// Open new
|
|
2032
|
+
|
|
2033
|
+
// Open new list or switch type at current level
|
|
1665
2034
|
if (listStack.length === level) {
|
|
1666
|
-
// Need to open a new list
|
|
1667
2035
|
listStack.push({ type: listType, level });
|
|
1668
2036
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
1669
2037
|
} else if (listStack.length === level + 1) {
|
|
1670
|
-
// Check if we need to switch list type
|
|
1671
2038
|
const currentList = listStack[listStack.length - 1];
|
|
1672
2039
|
if (currentList.type !== listType) {
|
|
1673
2040
|
result.push(`</${currentList.type}>`);
|
|
@@ -1676,11 +2043,11 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
1676
2043
|
result.push(`<${listType}${getAttr(listType)}>`);
|
|
1677
2044
|
}
|
|
1678
2045
|
}
|
|
1679
|
-
|
|
2046
|
+
|
|
1680
2047
|
const liAttr = taskListClass || getAttr('li');
|
|
1681
2048
|
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
|
|
1682
2049
|
} else {
|
|
1683
|
-
// Not a list item
|
|
2050
|
+
// Not a list item — close all open lists
|
|
1684
2051
|
while (listStack.length > 0) {
|
|
1685
2052
|
const list = listStack.pop();
|
|
1686
2053
|
result.push(`</${list.type}>`);
|
|
@@ -1688,76 +2055,76 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
|
|
|
1688
2055
|
result.push(line);
|
|
1689
2056
|
}
|
|
1690
2057
|
}
|
|
1691
|
-
|
|
1692
|
-
// Close any remaining lists
|
|
2058
|
+
|
|
2059
|
+
// Close any remaining open lists
|
|
1693
2060
|
while (listStack.length > 0) {
|
|
1694
2061
|
const list = listStack.pop();
|
|
1695
2062
|
result.push(`</${list.type}>`);
|
|
1696
2063
|
}
|
|
1697
|
-
|
|
2064
|
+
|
|
1698
2065
|
return result.join('\n');
|
|
1699
2066
|
}
|
|
1700
2067
|
|
|
2068
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2069
|
+
// Static API
|
|
2070
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2071
|
+
|
|
1701
2072
|
/**
|
|
1702
|
-
* Emit CSS
|
|
1703
|
-
*
|
|
1704
|
-
* @param {string}
|
|
1705
|
-
* @
|
|
2073
|
+
* Emit CSS rules for all quikdown elements.
|
|
2074
|
+
*
|
|
2075
|
+
* @param {string} prefix Class prefix (default: 'quikdown-')
|
|
2076
|
+
* @param {string} theme 'light' (default) or 'dark'
|
|
2077
|
+
* @returns {string} CSS text
|
|
1706
2078
|
*/
|
|
1707
2079
|
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
|
|
1708
2080
|
const styles = QUIKDOWN_STYLES;
|
|
1709
|
-
|
|
1710
|
-
// Define theme color overrides
|
|
2081
|
+
|
|
1711
2082
|
const themeOverrides = {
|
|
1712
2083
|
dark: {
|
|
1713
|
-
'#f4f4f4': '#2a2a2a',
|
|
1714
|
-
'#f0f0f0': '#2a2a2a',
|
|
1715
|
-
'#f2f2f2': '#2a2a2a',
|
|
1716
|
-
'#ddd': '#3a3a3a',
|
|
1717
|
-
'#06c': '#6db3f2',
|
|
2084
|
+
'#f4f4f4': '#2a2a2a', // pre background
|
|
2085
|
+
'#f0f0f0': '#2a2a2a', // code background
|
|
2086
|
+
'#f2f2f2': '#2a2a2a', // th background
|
|
2087
|
+
'#ddd': '#3a3a3a', // borders
|
|
2088
|
+
'#06c': '#6db3f2', // links
|
|
1718
2089
|
_textColor: '#e0e0e0'
|
|
1719
2090
|
},
|
|
1720
2091
|
light: {
|
|
1721
|
-
_textColor: '#333'
|
|
2092
|
+
_textColor: '#333'
|
|
1722
2093
|
}
|
|
1723
2094
|
};
|
|
1724
|
-
|
|
2095
|
+
|
|
1725
2096
|
let css = '';
|
|
1726
2097
|
for (const [tag, style] of Object.entries(styles)) {
|
|
1727
2098
|
let themedStyle = style;
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
if (!oldColor.startsWith('_')) {
|
|
1734
|
-
themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
// Add text color for certain elements in dark theme
|
|
1739
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1740
|
-
if (needsTextColor.includes(tag)) {
|
|
1741
|
-
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
1742
|
-
}
|
|
1743
|
-
} else if (theme === 'light' && themeOverrides.light) {
|
|
1744
|
-
// Add explicit text color for light theme elements too
|
|
1745
|
-
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
1746
|
-
if (needsTextColor.includes(tag)) {
|
|
1747
|
-
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
2099
|
+
|
|
2100
|
+
if (theme === 'dark' && themeOverrides.dark) {
|
|
2101
|
+
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
|
|
2102
|
+
if (!oldColor.startsWith('_')) {
|
|
2103
|
+
themedStyle = themedStyle.replaceAll(oldColor, newColor);
|
|
1748
2104
|
}
|
|
1749
2105
|
}
|
|
1750
|
-
|
|
2106
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
2107
|
+
if (needsTextColor.includes(tag)) {
|
|
2108
|
+
themedStyle += `;color:${themeOverrides.dark._textColor}`;
|
|
2109
|
+
}
|
|
2110
|
+
} else if (theme === 'light' && themeOverrides.light) {
|
|
2111
|
+
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
|
|
2112
|
+
if (needsTextColor.includes(tag)) {
|
|
2113
|
+
themedStyle += `;color:${themeOverrides.light._textColor}`;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
1751
2117
|
css += `.${prefix}${tag} { ${themedStyle} }\n`;
|
|
1752
2118
|
}
|
|
1753
|
-
|
|
2119
|
+
|
|
1754
2120
|
return css;
|
|
1755
2121
|
};
|
|
1756
2122
|
|
|
1757
2123
|
/**
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
* @
|
|
2124
|
+
* Create a pre-configured parser with baked-in options.
|
|
2125
|
+
*
|
|
2126
|
+
* @param {Object} options Options to bake in
|
|
2127
|
+
* @returns {Function} Configured quikdown(markdown) function
|
|
1761
2128
|
*/
|
|
1762
2129
|
quikdown.configure = function(options) {
|
|
1763
2130
|
return function(markdown) {
|
|
@@ -1765,18 +2132,19 @@ quikdown.configure = function(options) {
|
|
|
1765
2132
|
};
|
|
1766
2133
|
};
|
|
1767
2134
|
|
|
1768
|
-
/**
|
|
1769
|
-
* Version information
|
|
1770
|
-
*/
|
|
2135
|
+
/** Semantic version (injected at build time) */
|
|
1771
2136
|
quikdown.version = quikdownVersion;
|
|
1772
2137
|
|
|
1773
|
-
|
|
2138
|
+
|
|
2139
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2140
|
+
// Exports
|
|
2141
|
+
// ════════════════════════════════════════════════════════════════════
|
|
2142
|
+
|
|
1774
2143
|
/* istanbul ignore next */
|
|
1775
2144
|
if (typeof module !== 'undefined' && module.exports) {
|
|
1776
2145
|
module.exports = quikdown;
|
|
1777
2146
|
}
|
|
1778
2147
|
|
|
1779
|
-
// For browser global
|
|
1780
2148
|
/* istanbul ignore next */
|
|
1781
2149
|
if (typeof window !== 'undefined') {
|
|
1782
2150
|
window.quikdown = quikdown;
|
|
@@ -1800,8 +2168,11 @@ function quikdown_bd(markdown, options = {}) {
|
|
|
1800
2168
|
return quikdown(markdown, { ...options, bidirectional: true });
|
|
1801
2169
|
}
|
|
1802
2170
|
|
|
1803
|
-
// Copy all properties and methods from quikdown (including version)
|
|
2171
|
+
// Copy all properties and methods from quikdown (including version).
|
|
2172
|
+
// Skip `configure` — quikdown_bd provides its own override below, so the
|
|
2173
|
+
// inner quikdown.configure is dead code in this bundle.
|
|
1804
2174
|
Object.keys(quikdown).forEach(key => {
|
|
2175
|
+
if (key === 'configure') return;
|
|
1805
2176
|
quikdown_bd[key] = quikdown[key];
|
|
1806
2177
|
});
|
|
1807
2178
|
|
|
@@ -1835,7 +2206,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
1835
2206
|
|
|
1836
2207
|
// Process children with context
|
|
1837
2208
|
let childContent = '';
|
|
1838
|
-
for (
|
|
2209
|
+
for (const child of node.childNodes) {
|
|
1839
2210
|
childContent += walkNode(child, { parentTag: tag, ...parentContext });
|
|
1840
2211
|
}
|
|
1841
2212
|
|
|
@@ -2069,7 +2440,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
2069
2440
|
let index = 1;
|
|
2070
2441
|
const indent = ' '.repeat(depth);
|
|
2071
2442
|
|
|
2072
|
-
for (
|
|
2443
|
+
for (const child of listNode.children) {
|
|
2073
2444
|
if (child.tagName !== 'LI') continue;
|
|
2074
2445
|
|
|
2075
2446
|
const dataQd = child.getAttribute('data-qd');
|
|
@@ -2082,7 +2453,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
2082
2453
|
marker = '-';
|
|
2083
2454
|
// Get text without the checkbox
|
|
2084
2455
|
let text = '';
|
|
2085
|
-
for (
|
|
2456
|
+
for (const node of child.childNodes) {
|
|
2086
2457
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
2087
2458
|
text += node.textContent;
|
|
2088
2459
|
} else if (node.tagName && node.tagName !== 'INPUT') {
|
|
@@ -2093,7 +2464,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
2093
2464
|
} else {
|
|
2094
2465
|
let itemContent = '';
|
|
2095
2466
|
|
|
2096
|
-
for (
|
|
2467
|
+
for (const node of child.childNodes) {
|
|
2097
2468
|
if (node.tagName === 'UL' || node.tagName === 'OL') {
|
|
2098
2469
|
itemContent += walkList(node, node.tagName === 'OL', depth + 1);
|
|
2099
2470
|
} else {
|
|
@@ -2122,7 +2493,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
2122
2493
|
const headerRow = thead.querySelector('tr');
|
|
2123
2494
|
if (headerRow) {
|
|
2124
2495
|
const headers = [];
|
|
2125
|
-
for (
|
|
2496
|
+
for (const th of headerRow.querySelectorAll('th')) {
|
|
2126
2497
|
headers.push(th.textContent.trim());
|
|
2127
2498
|
}
|
|
2128
2499
|
result += '| ' + headers.join(' | ') + ' |\n';
|
|
@@ -2141,9 +2512,9 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
2141
2512
|
// Process body
|
|
2142
2513
|
const tbody = table.querySelector('tbody');
|
|
2143
2514
|
if (tbody) {
|
|
2144
|
-
for (
|
|
2515
|
+
for (const row of tbody.querySelectorAll('tr')) {
|
|
2145
2516
|
const cells = [];
|
|
2146
|
-
for (
|
|
2517
|
+
for (const td of row.querySelectorAll('td')) {
|
|
2147
2518
|
cells.push(td.textContent.trim());
|
|
2148
2519
|
}
|
|
2149
2520
|
if (cells.length > 0) {
|
|
@@ -2165,10 +2536,13 @@ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
|
|
|
2165
2536
|
return markdown;
|
|
2166
2537
|
};
|
|
2167
2538
|
|
|
2168
|
-
// Override the configure method to return a bidirectional version
|
|
2539
|
+
// Override the configure method to return a bidirectional version.
|
|
2540
|
+
// We delegate to the inner quikdown.configure so the shared closure
|
|
2541
|
+
// machinery is exercised in both bundles (no dead code).
|
|
2169
2542
|
quikdown_bd.configure = function(options) {
|
|
2543
|
+
const innerParser = quikdown.configure({ ...options, bidirectional: true });
|
|
2170
2544
|
return function(markdown) {
|
|
2171
|
-
return
|
|
2545
|
+
return innerParser(markdown);
|
|
2172
2546
|
};
|
|
2173
2547
|
};
|
|
2174
2548
|
|